From 8f3df6bca4170a0a6b314c866e087cf269ea900c Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Mon, 14 Oct 2024 20:30:33 -0400 Subject: [PATCH 1/8] [Vertex AI] Add `Citation.publicationDate` --- .../Sources/GenerateContentResponse.swift | 15 +++ .../Sources/Types/Internal/ProtoDate.swift | 112 +++++++++++++++++ .../Tests/Unit/Types/ProtoDateTests.swift | 117 ++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift create mode 100644 FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift diff --git a/FirebaseVertexAI/Sources/GenerateContentResponse.swift b/FirebaseVertexAI/Sources/GenerateContentResponse.swift index d786676ec80..9f64522ddb3 100644 --- a/FirebaseVertexAI/Sources/GenerateContentResponse.swift +++ b/FirebaseVertexAI/Sources/GenerateContentResponse.swift @@ -141,6 +141,8 @@ public struct Citation: Sendable { /// The license the cited source work is distributed under, if specified. public let license: String? + + public let publicationDate: Date? } /// A value enumerating possible reasons for a model to terminate a content generation request. @@ -363,6 +365,7 @@ extension Citation: Decodable { case uri case title case license + case publicationDate } public init(from decoder: any Decoder) throws { @@ -385,6 +388,18 @@ extension Citation: Decodable { } else { license = nil } + if let publicationProtoDate = try container.decodeIfPresent( + ProtoDate.self, + forKey: .publicationDate + ) { + if let publicationDate = publicationProtoDate.dateComponents.date { + self.publicationDate = publicationDate + } else { + publicationDate = nil + } + } else { + publicationDate = nil + } } } diff --git a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift new file mode 100644 index 00000000000..1906386fc63 --- /dev/null +++ b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift @@ -0,0 +1,112 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// Represents a whole or partial calendar date, such as a birthday. +/// +/// The time of day and time zone are either specified elsewhere or are insignificant. The date is +/// relative to the Gregorian Calendar. This can represent one of the following: +/// - A full date, with non-zero year, month, and day values +/// - A month and day value, with a zero year, such as an anniversary +/// - A year on its own, with zero month and day values +/// - A year and month value, with a zero day, such as a credit card expiration date +/// +/// This represents a `google.type.Date`. +struct ProtoDate { + /// Year of the date. + /// + /// Must be from 1 to 9999, or 0 to specify a date without a year. + let year: Int? + + /// Month of a year. + /// + /// Must be from 1 to 12, or 0 to specify a year without a month and day. + let month: Int? + + /// Day of a month. + /// + /// Must be from 1 to 31 and valid for the year and month, or 0 to specify a year by itself or a + /// year and month where the day isn't significant. + let day: Int? + + var dateComponents: DateComponents { + DateComponents( + calendar: Calendar.current, + timeZone: TimeZone.current, + year: year, + month: month, + day: day + ) + } + + init(year: Int?, month: Int?, day: Int?) { + self.year = year + self.month = month + self.day = day + } +} + +// MARK: - Codable Conformance + +extension ProtoDate: Decodable { + enum CodingKeys: CodingKey { + case year + case month + case day + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + if let year = try container.decodeIfPresent(Int.self, forKey: .year), year != 0 { + guard year > 0 else { + throw DecodingError.dataCorrupted( + .init(codingPath: [CodingKeys.year], debugDescription: "Invalid year: \(year)") + ) + } + self.year = year + } else { + year = nil + } + + if let month = try container.decodeIfPresent(Int.self, forKey: .month), month != 0 { + guard month > 0 else { + throw DecodingError.dataCorrupted( + .init(codingPath: [CodingKeys.month], debugDescription: "Invalid month: \(month)") + ) + } + self.month = month + } else { + month = nil + } + + if let day = try container.decodeIfPresent(Int.self, forKey: .day), day != 0 { + guard day > 0 else { + throw DecodingError.dataCorrupted( + .init(codingPath: [CodingKeys.day], debugDescription: "Invalid day: \(day)") + ) + } + self.day = day + } else { + day = nil + } + + guard year != nil || month != nil || day != nil else { + throw DecodingError.dataCorrupted(.init( + codingPath: [CodingKeys.year, CodingKeys.month, CodingKeys.day], + debugDescription: "Invalid date: missing year, month and day" + )) + } + } +} diff --git a/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift b/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift new file mode 100644 index 00000000000..84784061a35 --- /dev/null +++ b/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift @@ -0,0 +1,117 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest + +@testable import FirebaseVertexAI + +final class ProtoDateTests: XCTestCase { + let decoder = JSONDecoder() + + // A full date, with non-zero year, month, and day values. + func testProtoDate_fullDate_dateComponents() { + let year = 2024 + let month = 12 + let day = 31 + let protoDate = ProtoDate(year: year, month: month, day: day) + + let dateComponents = protoDate.dateComponents + + XCTAssertTrue(dateComponents.isValidDate) + XCTAssertEqual(dateComponents.year, year) + XCTAssertEqual(dateComponents.month, month) + XCTAssertEqual(dateComponents.day, day) + } + + // A month and day value, with a zero year, such as an anniversary. + func testProtoDate_monthDay_dateComponents() { + let month = 7 + let day = 1 + let protoDate = ProtoDate(year: nil, month: month, day: day) + + let dateComponents = protoDate.dateComponents + + XCTAssertTrue(dateComponents.isValidDate) + XCTAssertNil(dateComponents.year) + XCTAssertEqual(dateComponents.month, month) + XCTAssertEqual(dateComponents.day, day) + } + + // A year on its own, with zero month and day values. + func testProtoDate_yearOnly_dateComponents() { + let year = 2024 + let protoDate = ProtoDate(year: year, month: nil, day: nil) + + let dateComponents = protoDate.dateComponents + + XCTAssertTrue(dateComponents.isValidDate) + XCTAssertEqual(dateComponents.year, year) + XCTAssertNil(dateComponents.month) + XCTAssertNil(dateComponents.day) + } + + // A year and month value, with a zero day, such as a credit card expiration date + func testProtoDate_yearMonth_dateComponents() { + let year = 2024 + let month = 08 + let protoDate = ProtoDate(year: year, month: month, day: nil) + + let dateComponents = protoDate.dateComponents + + XCTAssertTrue(dateComponents.isValidDate) + XCTAssertEqual(protoDate.year, year) + XCTAssertEqual(protoDate.month, month) + XCTAssertEqual(protoDate.day, nil) + } + + func testProtoDate_asDate() throws { + let protoDate = ProtoDate(year: 2024, month: 12, day: 31) + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let expectedDate = try XCTUnwrap(dateFormatter.date(from: "2024-12-31")) + + let date = try XCTUnwrap(protoDate.dateComponents.date) + + XCTAssertEqual(date, expectedDate) + } + + func testDecodeProtoDate() throws { + let json = """ + { + "year" : 2024, + "month" : 12, + "day" : 31 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let protoDate = try decoder.decode(ProtoDate.self, from: jsonData) + + XCTAssertEqual(protoDate.year, 2024) + XCTAssertEqual(protoDate.month, 12) + XCTAssertEqual(protoDate.day, 31) + } + + func testDecodeProtoDate_emptyDate_throws() throws { + let json = "{}" + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + do { + _ = try decoder.decode(ProtoDate.self, from: jsonData) + } catch DecodingError.dataCorrupted { + return + } + XCTFail("Expected a DecodingError.") + } +} From 431e794a8adc1558d411208ae33110b9d9fbdaf9 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Oct 2024 11:31:56 -0400 Subject: [PATCH 2/8] Add date conversion error handling and more tests --- .../Sources/GenerateContentResponse.swift | 18 +- .../Sources/Types/Internal/ProtoDate.swift | 25 ++- .../Tests/Unit/Types/ProtoDateTests.swift | 186 +++++++++++++++++- 3 files changed, 220 insertions(+), 9 deletions(-) diff --git a/FirebaseVertexAI/Sources/GenerateContentResponse.swift b/FirebaseVertexAI/Sources/GenerateContentResponse.swift index 9f64522ddb3..9cfbd5c20f0 100644 --- a/FirebaseVertexAI/Sources/GenerateContentResponse.swift +++ b/FirebaseVertexAI/Sources/GenerateContentResponse.swift @@ -372,30 +372,40 @@ extension Citation: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) startIndex = try container.decodeIfPresent(Int.self, forKey: .startIndex) ?? 0 endIndex = try container.decode(Int.self, forKey: .endIndex) + if let uri = try container.decodeIfPresent(String.self, forKey: .uri), !uri.isEmpty { self.uri = uri } else { uri = nil } + if let title = try container.decodeIfPresent(String.self, forKey: .title), !title.isEmpty { self.title = title } else { title = nil } + if let license = try container.decodeIfPresent(String.self, forKey: .license), !license.isEmpty { self.license = license } else { license = nil } + if let publicationProtoDate = try container.decodeIfPresent( ProtoDate.self, forKey: .publicationDate ) { - if let publicationDate = publicationProtoDate.dateComponents.date { - self.publicationDate = publicationDate - } else { - publicationDate = nil + do { + publicationDate = try publicationProtoDate.asDate() + } catch let error as ProtoDate.DateConversionError { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: [CodingKeys.publicationDate], + debugDescription: "Invalid citation publicationDate.", + underlyingError: error + ) + ) } } else { publicationDate = nil diff --git a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift index 1906386fc63..cb3c89c1ef1 100644 --- a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift +++ b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift @@ -44,18 +44,41 @@ struct ProtoDate { var dateComponents: DateComponents { DateComponents( calendar: Calendar.current, - timeZone: TimeZone.current, year: year, month: month, day: day ) } + func asDate() throws -> Date { + guard year != nil else { + throw DateConversionError(message: "Missing a year: \(self)") + } + guard month != nil else { + throw DateConversionError(message: "Missing a month: \(self)") + } + guard day != nil else { + throw DateConversionError(message: "Missing a day: \(self)") + } + guard let date = dateComponents.date else { + throw DateConversionError(message: "Date conversion failed: \(self)") + } + return date + } + init(year: Int?, month: Int?, day: Int?) { self.year = year self.month = month self.day = day } + + struct DateConversionError: Error { + let localizedDescription: String + + init(message: String) { + localizedDescription = message + } + } } // MARK: - Codable Conformance diff --git a/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift b/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift index 84784061a35..a5b58bb88bc 100644 --- a/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift +++ b/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift @@ -19,6 +19,8 @@ import XCTest final class ProtoDateTests: XCTestCase { let decoder = JSONDecoder() + // MARK: - Date Components Tests + // A full date, with non-zero year, month, and day values. func testProtoDate_fullDate_dateComponents() { let year = 2024 @@ -64,7 +66,7 @@ final class ProtoDateTests: XCTestCase { // A year and month value, with a zero day, such as a credit card expiration date func testProtoDate_yearMonth_dateComponents() { let year = 2024 - let month = 08 + let month = 8 let protoDate = ProtoDate(year: year, month: month, day: nil) let dateComponents = protoDate.dateComponents @@ -75,18 +77,78 @@ final class ProtoDateTests: XCTestCase { XCTAssertEqual(protoDate.day, nil) } - func testProtoDate_asDate() throws { + // MARK: - Date Conversion Tests + + func testProtoDate_fullDate_asDate() throws { let protoDate = ProtoDate(year: 2024, month: 12, day: 31) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" let expectedDate = try XCTUnwrap(dateFormatter.date(from: "2024-12-31")) - let date = try XCTUnwrap(protoDate.dateComponents.date) + let date = try XCTUnwrap(protoDate.asDate()) XCTAssertEqual(date, expectedDate) } - func testDecodeProtoDate() throws { + func testProtoDate_monthDay_asDate_throws() { + let protoDate = ProtoDate(year: nil, month: 7, day: 1) + + do { + _ = try protoDate.asDate() + } catch let error as ProtoDate.DateConversionError { + XCTAssertTrue(error.localizedDescription.contains("Missing a year")) + return + } catch { + XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") + } + XCTFail("Expected an error but none thrown.") + } + + func testProtoDate_yearOnly_asDate_throws() { + let protoDate = ProtoDate(year: 2024, month: nil, day: nil) + + do { + _ = try protoDate.asDate() + } catch let error as ProtoDate.DateConversionError { + XCTAssertTrue(error.localizedDescription.contains("Missing a month")) + return + } catch { + XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") + } + XCTFail("Expected an error but none thrown.") + } + + func testProtoDate_yearMonth_asDate_throws() { + let protoDate = ProtoDate(year: 2024, month: 8, day: nil) + + do { + _ = try protoDate.asDate() + } catch let error as ProtoDate.DateConversionError { + XCTAssertTrue(error.localizedDescription.contains("Missing a day")) + return + } catch { + XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") + } + XCTFail("Expected an error but none thrown.") + } + + func testProtoDate_empty_asDate_throws() { + let protoDate = ProtoDate(year: nil, month: nil, day: nil) + + do { + _ = try protoDate.asDate() + } catch let error as ProtoDate.DateConversionError { + XCTAssertTrue(error.localizedDescription.contains("Missing a year")) + return + } catch { + XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") + } + XCTFail("Expected an error but none thrown.") + } + + // MARK: - Decoding Tests + + func testDecodeProtoDate_fullDate() throws { let json = """ { "year" : 2024, @@ -103,7 +165,123 @@ final class ProtoDateTests: XCTestCase { XCTAssertEqual(protoDate.day, 31) } + func testDecodeProtoDate_monthDay() throws { + let json = """ + { + "year": 0, + "month" : 12, + "day" : 31 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let protoDate = try decoder.decode(ProtoDate.self, from: jsonData) + + XCTAssertNil(protoDate.year) + XCTAssertEqual(protoDate.month, 12) + XCTAssertEqual(protoDate.day, 31) + } + + func testDecodeProtoDate_monthDay_defaultsOmitted() throws { + let json = """ + { + "month" : 12, + "day" : 31 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let protoDate = try decoder.decode(ProtoDate.self, from: jsonData) + + XCTAssertNil(protoDate.year) + XCTAssertEqual(protoDate.month, 12) + XCTAssertEqual(protoDate.day, 31) + } + + func testDecodeProtoDate_yearOnly() throws { + let json = """ + { + "year": 2024, + "month" : 0, + "day" : 0 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let protoDate = try decoder.decode(ProtoDate.self, from: jsonData) + + XCTAssertEqual(protoDate.year, 2024) + XCTAssertNil(protoDate.month) + XCTAssertNil(protoDate.day) + } + + func testDecodeProtoDate_yearOnly_defaultsOmitted() throws { + let json = """ + { + "year": 2024 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let protoDate = try decoder.decode(ProtoDate.self, from: jsonData) + + XCTAssertEqual(protoDate.year, 2024) + XCTAssertNil(protoDate.month) + XCTAssertNil(protoDate.day) + } + + func testDecodeProtoDate_yearMonth() throws { + let json = """ + { + "year": 2024, + "month" : 12, + "day": 0 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let protoDate = try decoder.decode(ProtoDate.self, from: jsonData) + + XCTAssertEqual(protoDate.year, 2024) + XCTAssertEqual(protoDate.month, 12) + XCTAssertNil(protoDate.day) + } + + func testDecodeProtoDate_yearMonth_defaultsOmitted() throws { + let json = """ + { + "year": 2024, + "month" : 12 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let protoDate = try decoder.decode(ProtoDate.self, from: jsonData) + + XCTAssertEqual(protoDate.year, 2024) + XCTAssertEqual(protoDate.month, 12) + XCTAssertNil(protoDate.day) + } + func testDecodeProtoDate_emptyDate_throws() throws { + let json = """ + { + "year": 0, + "month" : 0, + "day": 0 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + do { + _ = try decoder.decode(ProtoDate.self, from: jsonData) + } catch DecodingError.dataCorrupted { + return + } + XCTFail("Expected a DecodingError.") + } + + func testDecodeProtoDate_emptyDate_defaultsOmitted_throws() throws { let json = "{}" let jsonData = try XCTUnwrap(json.data(using: .utf8)) From 686f56b0ea7c98366ff3aa0120927cf8bf967e0f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Oct 2024 14:24:08 -0400 Subject: [PATCH 3/8] Add more ProtoData checks and tests --- .../Sources/Types/Internal/ProtoDate.swift | 13 +-- .../Tests/Unit/Types/ProtoDateTests.swift | 89 ++++++++++++++++++- 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift index cb3c89c1ef1..1ca141f5e01 100644 --- a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift +++ b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift @@ -23,7 +23,8 @@ import Foundation /// - A year on its own, with zero month and day values /// - A year and month value, with a zero day, such as a credit card expiration date /// -/// This represents a `google.type.Date`. +/// This represents a +/// [`google.type.Date`](https://cloud.google.com/vertex-ai/docs/reference/rest/Shared.Types/Date). struct ProtoDate { /// Year of the date. /// @@ -60,8 +61,8 @@ struct ProtoDate { guard day != nil else { throw DateConversionError(message: "Missing a day: \(self)") } - guard let date = dateComponents.date else { - throw DateConversionError(message: "Date conversion failed: \(self)") + guard dateComponents.isValidDate, let date = dateComponents.date else { + throw DateConversionError(message: "Invalid date: \(self)") } return date } @@ -93,7 +94,7 @@ extension ProtoDate: Decodable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let year = try container.decodeIfPresent(Int.self, forKey: .year), year != 0 { - guard year > 0 else { + guard year >= 1 && year <= 9999 else { throw DecodingError.dataCorrupted( .init(codingPath: [CodingKeys.year], debugDescription: "Invalid year: \(year)") ) @@ -104,7 +105,7 @@ extension ProtoDate: Decodable { } if let month = try container.decodeIfPresent(Int.self, forKey: .month), month != 0 { - guard month > 0 else { + guard month >= 1 && month <= 12 else { throw DecodingError.dataCorrupted( .init(codingPath: [CodingKeys.month], debugDescription: "Invalid month: \(month)") ) @@ -115,7 +116,7 @@ extension ProtoDate: Decodable { } if let day = try container.decodeIfPresent(Int.self, forKey: .day), day != 0 { - guard day > 0 else { + guard day >= 1 && day <= 31 else { throw DecodingError.dataCorrupted( .init(codingPath: [CodingKeys.day], debugDescription: "Invalid day: \(day)") ) diff --git a/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift b/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift index a5b58bb88bc..dec0b429973 100644 --- a/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift +++ b/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift @@ -132,6 +132,21 @@ final class ProtoDateTests: XCTestCase { XCTFail("Expected an error but none thrown.") } + func testProtoDate_invalidDate_asDate_throws() { + // Reminder: Only 30 days in November + let protoDate = ProtoDate(year: 2024, month: 11, day: 31) + + do { + _ = try protoDate.asDate() + } catch let error as ProtoDate.DateConversionError { + XCTAssertTrue(error.localizedDescription.contains("Invalid date")) + return + } catch { + XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") + } + XCTFail("Expected an error but none thrown.") + } + func testProtoDate_empty_asDate_throws() { let protoDate = ProtoDate(year: nil, month: nil, day: nil) @@ -275,7 +290,12 @@ final class ProtoDateTests: XCTestCase { do { _ = try decoder.decode(ProtoDate.self, from: jsonData) - } catch DecodingError.dataCorrupted { + } catch let DecodingError.dataCorrupted(context) { + XCTAssertEqual( + context.codingPath as? [ProtoDate.CodingKeys], + [ProtoDate.CodingKeys.year, ProtoDate.CodingKeys.month, ProtoDate.CodingKeys.day] + ) + XCTAssertTrue(context.debugDescription.contains("Invalid date")) return } XCTFail("Expected a DecodingError.") @@ -287,7 +307,72 @@ final class ProtoDateTests: XCTestCase { do { _ = try decoder.decode(ProtoDate.self, from: jsonData) - } catch DecodingError.dataCorrupted { + } catch let DecodingError.dataCorrupted(context) { + XCTAssertEqual( + context.codingPath as? [ProtoDate.CodingKeys], + [ProtoDate.CodingKeys.year, ProtoDate.CodingKeys.month, ProtoDate.CodingKeys.day] + ) + XCTAssertTrue(context.debugDescription.contains("Invalid date")) + return + } + XCTFail("Expected a DecodingError.") + } + + func testDecodeProtoDate_invalidYear_throws() throws { + let json = """ + { + "year" : -2024, + "month" : 12, + "day" : 31 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + do { + _ = try decoder.decode(ProtoDate.self, from: jsonData) + } catch let DecodingError.dataCorrupted(context) { + XCTAssertEqual(context.codingPath as? [ProtoDate.CodingKeys], [ProtoDate.CodingKeys.year]) + XCTAssertTrue(context.debugDescription.contains("Invalid year")) + return + } + XCTFail("Expected a DecodingError.") + } + + func testDecodeProtoDate_invalidMonth_throws() throws { + let json = """ + { + "year" : 2024, + "month" : 13, + "day" : 31 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + do { + _ = try decoder.decode(ProtoDate.self, from: jsonData) + } catch let DecodingError.dataCorrupted(context) { + XCTAssertEqual(context.codingPath as? [ProtoDate.CodingKeys], [ProtoDate.CodingKeys.month]) + XCTAssertTrue(context.debugDescription.contains("Invalid month")) + return + } + XCTFail("Expected a DecodingError.") + } + + func testDecodeProtoDate_invalidDay_throws() throws { + let json = """ + { + "year" : 2024, + "month" : 12, + "day" : 32 + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + do { + _ = try decoder.decode(ProtoDate.self, from: jsonData) + } catch let DecodingError.dataCorrupted(context) { + XCTAssertEqual(context.codingPath as? [ProtoDate.CodingKeys], [ProtoDate.CodingKeys.day]) + XCTAssertTrue(context.debugDescription.contains("Invalid day")) return } XCTFail("Expected a DecodingError.") From 7816cd2b5f88596dfa9d6b5e413f79322278953d Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Oct 2024 14:24:40 -0400 Subject: [PATCH 4/8] Update `GenerativeModelTests` to check `publicationDate` --- .../Tests/Unit/GenerativeModelTests.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift index 7b6b1c15336..786368c84b9 100644 --- a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift +++ b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift @@ -58,6 +58,11 @@ final class GenerativeModelTests: XCTestCase { ].sorted() let testModelResourceName = "projects/test-project-id/locations/test-location/publishers/google/models/test-model" + let dateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + return dateFormatter + }() var urlSession: URLSession! var model: GenerativeModel! @@ -134,6 +139,7 @@ final class GenerativeModelTests: XCTestCase { forResource: "unary-success-citations", withExtension: "json" ) + let expectedPublicationDate = try XCTUnwrap(dateFormatter.date(from: "2019-05-10")) let response = try await model.generateContent(testPrompt) @@ -149,8 +155,10 @@ final class GenerativeModelTests: XCTestCase { XCTAssertEqual(citationSource1.endIndex, 128) XCTAssertNil(citationSource1.title) XCTAssertNil(citationSource1.license) + XCTAssertNil(citationSource1.publicationDate) let citationSource2 = try XCTUnwrap(citationMetadata.citations[1]) XCTAssertEqual(citationSource2.title, "some-citation-2") + XCTAssertEqual(citationSource2.publicationDate, expectedPublicationDate) XCTAssertEqual(citationSource2.startIndex, 130) XCTAssertEqual(citationSource2.endIndex, 265) XCTAssertNil(citationSource2.uri) @@ -161,6 +169,7 @@ final class GenerativeModelTests: XCTestCase { XCTAssertEqual(citationSource3.endIndex, 431) XCTAssertEqual(citationSource3.license, "mit") XCTAssertNil(citationSource3.title) + XCTAssertNil(citationSource3.publicationDate) } func testGenerateContent_success_quoteReply() async throws { @@ -1052,6 +1061,7 @@ final class GenerativeModelTests: XCTestCase { forResource: "streaming-success-citations", withExtension: "txt" ) + let expectedPublicationDate = try XCTUnwrap(dateFormatter.date(from: "2014-03-30")) let stream = try model.generateContentStream("Hi") var citations = [Citation]() @@ -1072,18 +1082,19 @@ final class GenerativeModelTests: XCTestCase { .contains { $0.startIndex == 0 && $0.endIndex == 128 && $0.uri == "https://www.example.com/some-citation-1" && $0.title == nil - && $0.license == nil + && $0.license == nil && $0.publicationDate == nil }) XCTAssertTrue(citations .contains { $0.startIndex == 130 && $0.endIndex == 265 && $0.uri == nil && $0.title == "some-citation-2" && $0.license == nil + && $0.publicationDate == expectedPublicationDate }) XCTAssertTrue(citations .contains { $0.startIndex == 272 && $0.endIndex == 431 && $0.uri == "https://www.example.com/some-citation-3" && $0.title == nil - && $0.license == "mit" + && $0.license == "mit" && $0.publicationDate == nil }) XCTAssertFalse(citations.contains { $0.uri?.isEmpty ?? false }) XCTAssertFalse(citations.contains { $0.title?.isEmpty ?? false }) From 6b9969dff36c6aa55410687dd2de00d7ece6122f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Oct 2024 14:28:16 -0400 Subject: [PATCH 5/8] Add changelog entry --- FirebaseVertexAI/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/FirebaseVertexAI/CHANGELOG.md b/FirebaseVertexAI/CHANGELOG.md index d0d95a82070..5c777915ead 100644 --- a/FirebaseVertexAI/CHANGELOG.md +++ b/FirebaseVertexAI/CHANGELOG.md @@ -72,6 +72,8 @@ that may be reported when a prompt is blocked. (#13861) - [added] Added the `PromptFeedback` property `blockReasonMessage` that *may* be provided alongside the `blockReason`. (#13891) +- [added] Added an optional `publicationDate` property that *may* be provided in + `Citation`. (#13893) # 11.3.0 - [added] Added `Decodable` conformance for `FunctionResponse`. (#13606) From a388d9f78b48603075cb737ad8c1facb5a0a7a22 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Oct 2024 14:48:54 -0400 Subject: [PATCH 6/8] Add documentation and switch to Gregorian calendar --- .../Sources/GenerateContentResponse.swift | 1 + .../Sources/Types/Internal/ProtoDate.swift | 15 ++++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/FirebaseVertexAI/Sources/GenerateContentResponse.swift b/FirebaseVertexAI/Sources/GenerateContentResponse.swift index 9cfbd5c20f0..bd213f8c099 100644 --- a/FirebaseVertexAI/Sources/GenerateContentResponse.swift +++ b/FirebaseVertexAI/Sources/GenerateContentResponse.swift @@ -142,6 +142,7 @@ public struct Citation: Sendable { /// The license the cited source work is distributed under, if specified. public let license: String? + /// The publication date of the cited source, if available. public let publicationDate: Date? } diff --git a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift index 1ca141f5e01..d605d47d08d 100644 --- a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift +++ b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift @@ -42,15 +42,22 @@ struct ProtoDate { /// year and month where the day isn't significant. let day: Int? + /// Returns the a `DateComponents` representation of the `ProtoDate`. + /// + /// > Note: This uses the Gregorian `Calendar` to match the `google.type.Date` definition. var dateComponents: DateComponents { DateComponents( - calendar: Calendar.current, + calendar: Calendar(identifier: .gregorian), year: year, month: month, day: day ) } + /// Returns a `Date` representation of the `ProtoDate`. + /// + /// - Throws: An error of type `DateConversionError` if the `ProtoDate` cannot be represented as + /// a `Date`. func asDate() throws -> Date { guard year != nil else { throw DateConversionError(message: "Missing a year: \(self)") @@ -67,12 +74,6 @@ struct ProtoDate { return date } - init(year: Int?, month: Int?, day: Int?) { - self.year = year - self.month = month - self.day = day - } - struct DateConversionError: Error { let localizedDescription: String From 29a1d74c93ff3936efa905ce8c8b0e87222d0bee Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Oct 2024 17:48:45 -0400 Subject: [PATCH 7/8] Add expected ranges to year, month, day decoding errors --- .../Sources/Types/Internal/ProtoDate.swift | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift index d605d47d08d..d2d02583617 100644 --- a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift +++ b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift @@ -97,7 +97,12 @@ extension ProtoDate: Decodable { if let year = try container.decodeIfPresent(Int.self, forKey: .year), year != 0 { guard year >= 1 && year <= 9999 else { throw DecodingError.dataCorrupted( - .init(codingPath: [CodingKeys.year], debugDescription: "Invalid year: \(year)") + DecodingError.Context( + codingPath: [CodingKeys.year], + debugDescription: """ + Invalid year: \(year); must be from 1 to 9999, or 0 for a date without a specified year. + """ + ) ) } self.year = year @@ -108,7 +113,13 @@ extension ProtoDate: Decodable { if let month = try container.decodeIfPresent(Int.self, forKey: .month), month != 0 { guard month >= 1 && month <= 12 else { throw DecodingError.dataCorrupted( - .init(codingPath: [CodingKeys.month], debugDescription: "Invalid month: \(month)") + DecodingError.Context( + codingPath: [CodingKeys.month], + debugDescription: """ + Invalid month: \(month); must be from 1 to 12, or 0 for a year date without a \ + specified month and day. + """ + ) ) } self.month = month @@ -119,7 +130,12 @@ extension ProtoDate: Decodable { if let day = try container.decodeIfPresent(Int.self, forKey: .day), day != 0 { guard day >= 1 && day <= 31 else { throw DecodingError.dataCorrupted( - .init(codingPath: [CodingKeys.day], debugDescription: "Invalid day: \(day)") + DecodingError.Context( + codingPath: [CodingKeys.day], + debugDescription: """ + Invalid day: \(day); must be from 1 to 31, or 0 for a date without a specified day. + """ + ) ) } self.day = day From 5dc2cb318d9767ee86a84f682b7c1bfd1b6eab90 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 15 Oct 2024 18:29:41 -0400 Subject: [PATCH 8/8] Refactor to use `DateComponents` in public API instead of `Date` --- .../Sources/GenerateContentResponse.swift | 18 +-- .../Sources/Types/Internal/ProtoDate.swift | 70 +++------ FirebaseVertexAI/Sources/VertexLog.swift | 4 + .../Tests/Unit/GenerativeModelTests.swift | 19 ++- .../Tests/Unit/Types/ProtoDateTests.swift | 144 ------------------ 5 files changed, 41 insertions(+), 214 deletions(-) diff --git a/FirebaseVertexAI/Sources/GenerateContentResponse.swift b/FirebaseVertexAI/Sources/GenerateContentResponse.swift index bd213f8c099..fa736cbd1f0 100644 --- a/FirebaseVertexAI/Sources/GenerateContentResponse.swift +++ b/FirebaseVertexAI/Sources/GenerateContentResponse.swift @@ -143,7 +143,9 @@ public struct Citation: Sendable { public let license: String? /// The publication date of the cited source, if available. - public let publicationDate: Date? + /// + /// > Tip: `DateComponents` can be converted to a `Date` using the `date` computed property. + public let publicationDate: DateComponents? } /// A value enumerating possible reasons for a model to terminate a content generation request. @@ -397,15 +399,11 @@ extension Citation: Decodable { ProtoDate.self, forKey: .publicationDate ) { - do { - publicationDate = try publicationProtoDate.asDate() - } catch let error as ProtoDate.DateConversionError { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: [CodingKeys.publicationDate], - debugDescription: "Invalid citation publicationDate.", - underlyingError: error - ) + publicationDate = publicationProtoDate.dateComponents + if let publicationDate, !publicationDate.isValidDate { + VertexLog.warning( + code: .decodedInvalidCitationPublicationDate, + "Decoded an invalid citation publication date: \(publicationDate)" ) } } else { diff --git a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift index d2d02583617..fed7e63e943 100644 --- a/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift +++ b/FirebaseVertexAI/Sources/Types/Internal/ProtoDate.swift @@ -53,34 +53,6 @@ struct ProtoDate { day: day ) } - - /// Returns a `Date` representation of the `ProtoDate`. - /// - /// - Throws: An error of type `DateConversionError` if the `ProtoDate` cannot be represented as - /// a `Date`. - func asDate() throws -> Date { - guard year != nil else { - throw DateConversionError(message: "Missing a year: \(self)") - } - guard month != nil else { - throw DateConversionError(message: "Missing a month: \(self)") - } - guard day != nil else { - throw DateConversionError(message: "Missing a day: \(self)") - } - guard dateComponents.isValidDate, let date = dateComponents.date else { - throw DateConversionError(message: "Invalid date: \(self)") - } - return date - } - - struct DateConversionError: Error { - let localizedDescription: String - - init(message: String) { - localizedDescription = message - } - } } // MARK: - Codable Conformance @@ -95,14 +67,12 @@ extension ProtoDate: Decodable { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if let year = try container.decodeIfPresent(Int.self, forKey: .year), year != 0 { - guard year >= 1 && year <= 9999 else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: [CodingKeys.year], - debugDescription: """ - Invalid year: \(year); must be from 1 to 9999, or 0 for a date without a specified year. - """ - ) + if year < 0 || year > 9999 { + VertexLog.warning( + code: .decodedInvalidProtoDateYear, + """ + Invalid year: \(year); must be from 1 to 9999, or 0 for a date without a specified year. + """ ) } self.year = year @@ -111,15 +81,13 @@ extension ProtoDate: Decodable { } if let month = try container.decodeIfPresent(Int.self, forKey: .month), month != 0 { - guard month >= 1 && month <= 12 else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: [CodingKeys.month], - debugDescription: """ - Invalid month: \(month); must be from 1 to 12, or 0 for a year date without a \ - specified month and day. - """ - ) + if month < 0 || month > 12 { + VertexLog.warning( + code: .decodedInvalidProtoDateMonth, + """ + Invalid month: \(month); must be from 1 to 12, or 0 for a year date without a specified \ + month and day. + """ ) } self.month = month @@ -128,14 +96,10 @@ extension ProtoDate: Decodable { } if let day = try container.decodeIfPresent(Int.self, forKey: .day), day != 0 { - guard day >= 1 && day <= 31 else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: [CodingKeys.day], - debugDescription: """ - Invalid day: \(day); must be from 1 to 31, or 0 for a date without a specified day. - """ - ) + if day < 0 || day > 31 { + VertexLog.warning( + code: .decodedInvalidProtoDateDay, + "Invalid day: \(day); must be from 1 to 31, or 0 for a date without a specified day." ) } self.day = day diff --git a/FirebaseVertexAI/Sources/VertexLog.swift b/FirebaseVertexAI/Sources/VertexLog.swift index 7ffaf78f0fc..2ce1dac3cb0 100644 --- a/FirebaseVertexAI/Sources/VertexLog.swift +++ b/FirebaseVertexAI/Sources/VertexLog.swift @@ -50,6 +50,10 @@ enum VertexLog { case generateContentResponseUnrecognizedHarmProbability = 3005 case generateContentResponseUnrecognizedHarmCategory = 3006 case generateContentResponseUnrecognizedHarmSeverity = 3007 + case decodedInvalidProtoDateYear = 3008 + case decodedInvalidProtoDateMonth = 3009 + case decodedInvalidProtoDateDay = 3010 + case decodedInvalidCitationPublicationDate = 3011 // SDK State Errors case generateContentResponseNoCandidates = 4000 diff --git a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift index 786368c84b9..f9035949f1a 100644 --- a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift +++ b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift @@ -58,11 +58,6 @@ final class GenerativeModelTests: XCTestCase { ].sorted() let testModelResourceName = "projects/test-project-id/locations/test-location/publishers/google/models/test-model" - let dateFormatter = { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - return dateFormatter - }() var urlSession: URLSession! var model: GenerativeModel! @@ -139,7 +134,12 @@ final class GenerativeModelTests: XCTestCase { forResource: "unary-success-citations", withExtension: "json" ) - let expectedPublicationDate = try XCTUnwrap(dateFormatter.date(from: "2019-05-10")) + let expectedPublicationDate = DateComponents( + calendar: Calendar(identifier: .gregorian), + year: 2019, + month: 5, + day: 10 + ) let response = try await model.generateContent(testPrompt) @@ -1061,7 +1061,12 @@ final class GenerativeModelTests: XCTestCase { forResource: "streaming-success-citations", withExtension: "txt" ) - let expectedPublicationDate = try XCTUnwrap(dateFormatter.date(from: "2014-03-30")) + let expectedPublicationDate = DateComponents( + calendar: Calendar(identifier: .gregorian), + year: 2014, + month: 3, + day: 30 + ) let stream = try model.generateContentStream("Hi") var citations = [Citation]() diff --git a/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift b/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift index dec0b429973..0e6816c43df 100644 --- a/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift +++ b/FirebaseVertexAI/Tests/Unit/Types/ProtoDateTests.swift @@ -77,90 +77,6 @@ final class ProtoDateTests: XCTestCase { XCTAssertEqual(protoDate.day, nil) } - // MARK: - Date Conversion Tests - - func testProtoDate_fullDate_asDate() throws { - let protoDate = ProtoDate(year: 2024, month: 12, day: 31) - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - let expectedDate = try XCTUnwrap(dateFormatter.date(from: "2024-12-31")) - - let date = try XCTUnwrap(protoDate.asDate()) - - XCTAssertEqual(date, expectedDate) - } - - func testProtoDate_monthDay_asDate_throws() { - let protoDate = ProtoDate(year: nil, month: 7, day: 1) - - do { - _ = try protoDate.asDate() - } catch let error as ProtoDate.DateConversionError { - XCTAssertTrue(error.localizedDescription.contains("Missing a year")) - return - } catch { - XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") - } - XCTFail("Expected an error but none thrown.") - } - - func testProtoDate_yearOnly_asDate_throws() { - let protoDate = ProtoDate(year: 2024, month: nil, day: nil) - - do { - _ = try protoDate.asDate() - } catch let error as ProtoDate.DateConversionError { - XCTAssertTrue(error.localizedDescription.contains("Missing a month")) - return - } catch { - XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") - } - XCTFail("Expected an error but none thrown.") - } - - func testProtoDate_yearMonth_asDate_throws() { - let protoDate = ProtoDate(year: 2024, month: 8, day: nil) - - do { - _ = try protoDate.asDate() - } catch let error as ProtoDate.DateConversionError { - XCTAssertTrue(error.localizedDescription.contains("Missing a day")) - return - } catch { - XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") - } - XCTFail("Expected an error but none thrown.") - } - - func testProtoDate_invalidDate_asDate_throws() { - // Reminder: Only 30 days in November - let protoDate = ProtoDate(year: 2024, month: 11, day: 31) - - do { - _ = try protoDate.asDate() - } catch let error as ProtoDate.DateConversionError { - XCTAssertTrue(error.localizedDescription.contains("Invalid date")) - return - } catch { - XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") - } - XCTFail("Expected an error but none thrown.") - } - - func testProtoDate_empty_asDate_throws() { - let protoDate = ProtoDate(year: nil, month: nil, day: nil) - - do { - _ = try protoDate.asDate() - } catch let error as ProtoDate.DateConversionError { - XCTAssertTrue(error.localizedDescription.contains("Missing a year")) - return - } catch { - XCTFail("Expected \(ProtoDate.DateConversionError.self), got \(error).") - } - XCTFail("Expected an error but none thrown.") - } - // MARK: - Decoding Tests func testDecodeProtoDate_fullDate() throws { @@ -317,64 +233,4 @@ final class ProtoDateTests: XCTestCase { } XCTFail("Expected a DecodingError.") } - - func testDecodeProtoDate_invalidYear_throws() throws { - let json = """ - { - "year" : -2024, - "month" : 12, - "day" : 31 - } - """ - let jsonData = try XCTUnwrap(json.data(using: .utf8)) - - do { - _ = try decoder.decode(ProtoDate.self, from: jsonData) - } catch let DecodingError.dataCorrupted(context) { - XCTAssertEqual(context.codingPath as? [ProtoDate.CodingKeys], [ProtoDate.CodingKeys.year]) - XCTAssertTrue(context.debugDescription.contains("Invalid year")) - return - } - XCTFail("Expected a DecodingError.") - } - - func testDecodeProtoDate_invalidMonth_throws() throws { - let json = """ - { - "year" : 2024, - "month" : 13, - "day" : 31 - } - """ - let jsonData = try XCTUnwrap(json.data(using: .utf8)) - - do { - _ = try decoder.decode(ProtoDate.self, from: jsonData) - } catch let DecodingError.dataCorrupted(context) { - XCTAssertEqual(context.codingPath as? [ProtoDate.CodingKeys], [ProtoDate.CodingKeys.month]) - XCTAssertTrue(context.debugDescription.contains("Invalid month")) - return - } - XCTFail("Expected a DecodingError.") - } - - func testDecodeProtoDate_invalidDay_throws() throws { - let json = """ - { - "year" : 2024, - "month" : 12, - "day" : 32 - } - """ - let jsonData = try XCTUnwrap(json.data(using: .utf8)) - - do { - _ = try decoder.decode(ProtoDate.self, from: jsonData) - } catch let DecodingError.dataCorrupted(context) { - XCTAssertEqual(context.codingPath as? [ProtoDate.CodingKeys], [ProtoDate.CodingKeys.day]) - XCTAssertTrue(context.debugDescription.contains("Invalid day")) - return - } - XCTFail("Expected a DecodingError.") - } }