diff --git a/Sources/AWSLambdaEvents/Utils/DateWrappers.swift b/Sources/AWSLambdaEvents/Utils/DateWrappers.swift index d4f414b..605db18 100644 --- a/Sources/AWSLambdaEvents/Utils/DateWrappers.swift +++ b/Sources/AWSLambdaEvents/Utils/DateWrappers.swift @@ -12,7 +12,11 @@ // //===----------------------------------------------------------------------===// +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif @propertyWrapper public struct ISO8601Coding: Decodable, Sendable { @@ -25,16 +29,27 @@ public struct ISO8601Coding: Decodable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) - guard let date = Self.dateFormatter.date(from: dateString) else { + + guard let date = Self.parseISO8601(dateString: dateString) else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "Expected date to be in ISO8601 date format, but `\(dateString)` is not in the correct format" ) } + self.wrappedValue = date } + private static func parseISO8601(dateString: String) -> Date? { + #if canImport(FoundationEssentials) + return try? Date(dateString, strategy: .iso8601) + #else + return Self.dateFormatter.date(from: dateString) + #endif + } + + #if !canImport(FoundationEssentials) private static var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -42,6 +57,7 @@ public struct ISO8601Coding: Decodable, Sendable { formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" return formatter } + #endif } @propertyWrapper @@ -55,16 +71,31 @@ public struct ISO8601WithFractionalSecondsCoding: Decodable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let dateString = try container.decode(String.self) - guard let date = Self.dateFormatter.date(from: dateString) else { + + guard let date = Self.parseISO8601WithFractionalSeconds(dateString: dateString) else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "Expected date to be in ISO8601 date format with fractional seconds, but `\(dateString)` is not in the correct format" ) } + self.wrappedValue = date } + private static func parseISO8601WithFractionalSeconds(dateString: String) -> Date? { + #if canImport(FoundationEssentials) + return try? Date(dateString, strategy: Self.iso8601WithFractionalSeconds) + #else + return Self.dateFormatter.date(from: dateString) + #endif + } + + #if canImport(FoundationEssentials) + private static var iso8601WithFractionalSeconds: Date.ISO8601FormatStyle { + Date.ISO8601FormatStyle(includingFractionalSeconds: true) + } + #else private static var dateFormatter: DateFormatter { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -72,6 +103,7 @@ public struct ISO8601WithFractionalSecondsCoding: Decodable, Sendable { formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" return formatter } + #endif } @propertyWrapper @@ -84,34 +116,24 @@ public struct RFC5322DateTimeCoding: Decodable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - var string = try container.decode(String.self) - // RFC5322 dates sometimes have the alphabetic version of the timezone in brackets after the numeric version. The date formatter - // fails to parse this so we need to remove this before parsing. - if let bracket = string.firstIndex(of: "(") { - string = String(string[string.startIndex.. Date { + guard let components = self.components(from: input) else { + throw RFC5322DateParsingError() + } + guard let date = components.date else { + throw RFC5322DateParsingError() + } + return date + } + + func components(from input: String) -> DateComponents? { + var endIndex = input.endIndex + // If the date string has a timezone in brackets, we need to remove it before parsing. + if let bracket = input.firstIndex(of: "(") { + endIndex = bracket + } + var s = input[input.startIndex.. DateComponents? in + func parseDay(_ it: inout UnsafeBufferPointer.Iterator) -> Int? { + let first = it.next() + let second = it.next() + guard let first = first, let second = second else { return nil } + + guard asciiDigits.contains(first) else { return nil } + + let day: Int + if asciiDigits.contains(second) { + day = Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0")) + } else { + day = Int(first - UInt8(ascii: "0")) + } + + guard self.calendar.maximumRange(of: .day)!.contains(day) else { return nil } + + return day + } + + func parseMonth(_ it: inout UnsafeBufferPointer.Iterator) -> Int? { + let first = it.nextAsciiLetter(skippingWhitespace: true) + let second = it.nextAsciiLetter() + let third = it.nextAsciiLetter() + guard let first = first, let second = second, let third = third else { return nil } + guard first.isAsciiLetter else { return nil } + guard let month = monthMap[[first, second, third]] else { return nil } + guard self.calendar.maximumRange(of: .month)!.contains(month) else { return nil } + return month + } + + func parseYear(_ it: inout UnsafeBufferPointer.Iterator) -> Int? { + let first = it.nextAsciiDigit(skippingWhitespace: true) + let second = it.nextAsciiDigit() + let third = it.nextAsciiDigit() + let fourth = it.nextAsciiDigit() + guard let first = first, + let second = second, + let third = third, + let fourth = fourth + else { return nil } + return Int(first - UInt8(ascii: "0")) * 1000 + + Int(second - UInt8(ascii: "0")) * 100 + + Int(third - UInt8(ascii: "0")) * 10 + + Int(fourth - UInt8(ascii: "0")) + } + + func parseHour(_ it: inout UnsafeBufferPointer.Iterator) -> Int? { + let first = it.nextAsciiDigit(skippingWhitespace: true) + let second = it.nextAsciiDigit() + guard let first = first, let second = second else { return nil } + let hour = Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0")) + guard self.calendar.maximumRange(of: .hour)!.contains(hour) else { return nil } + return hour + } + + func parseMinute(_ it: inout UnsafeBufferPointer.Iterator) -> Int? { + let first = it.nextAsciiDigit(skippingWhitespace: true) + let second = it.nextAsciiDigit() + guard let first = first, let second = second else { return nil } + let minute = Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0")) + guard self.calendar.maximumRange(of: .minute)!.contains(minute) else { return nil } + return minute + } + + func parseSecond(_ it: inout UnsafeBufferPointer.Iterator) -> Int? { + let first = it.nextAsciiDigit(skippingWhitespace: true) + let second = it.nextAsciiDigit() + guard let first = first, let second = second else { return nil } + let value = Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0")) + guard self.calendar.maximumRange(of: .second)!.contains(value) else { return nil } + return value + } + + func parseTimezone(_ it: inout UnsafeBufferPointer.Iterator) -> Int? { + let plusMinus = it.nextSkippingWhitespace() + if let plusMinus, plusMinus == UInt8(ascii: "+") || plusMinus == UInt8(ascii: "-") { + let hour = parseHour(&it) + let minute = parseMinute(&it) + guard let hour = hour, let minute = minute else { return nil } + return (hour * 60 + minute) * (plusMinus == UInt8(ascii: "+") ? 1 : -1) + } else if let first = plusMinus { + let second = it.nextAsciiLetter() + let third = it.nextAsciiLetter() + + guard let second = second, let third = third else { return nil } + let abbr = [first, second, third] + return timezoneOffsetMap[abbr] + } + + return nil + } + + var it = buffer.makeIterator() + + // if the 4th character is a comma, then we have a day of the week + guard buffer.count > 5 else { return nil } + + if buffer[3] == UInt8(ascii: ",") { + for _ in 0..<5 { + _ = it.next() + } + } + + guard let day = parseDay(&it) else { return nil } + guard let month = parseMonth(&it) else { return nil } + guard let year = parseYear(&it) else { return nil } + + guard let hour = parseHour(&it) else { return nil } + guard it.expect(UInt8(ascii: ":")) else { return nil } + guard let minute = parseMinute(&it) else { return nil } + guard it.expect(UInt8(ascii: ":")) else { return nil } + guard let second = parseSecond(&it) else { return nil } + + guard let timezoneOffsetMinutes = parseTimezone(&it) else { return nil } + + return DateComponents( + calendar: self.calendar, + timeZone: TimeZone(secondsFromGMT: timezoneOffsetMinutes * 60), + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second + ) + } + } +} + +#if canImport(FoundationEssentials) +extension RFC5322DateParseStrategy: ParseStrategy {} +#endif + +extension IteratorProtocol where Self.Element == UInt8 { + mutating func expect(_ expected: UInt8) -> Bool { + guard self.next() == expected else { return false } + return true + } + + mutating func nextSkippingWhitespace() -> UInt8? { + while let c = self.next() { + if c != UInt8(ascii: " ") { + return c + } + } + return nil + } + + mutating func nextAsciiDigit(skippingWhitespace: Bool = false) -> UInt8? { + while let c = self.next() { + if skippingWhitespace { + if c == UInt8(ascii: " ") { + continue + } + } + switch c { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return c + default: return nil + } + } + return nil + } + + mutating func nextAsciiLetter(skippingWhitespace: Bool = false) -> UInt8? { + while let c = self.next() { + if skippingWhitespace { + if c == UInt8(ascii: " ") { + continue + } + } + + switch c { + case UInt8(ascii: "A")...UInt8(ascii: "Z"), + UInt8(ascii: "a")...UInt8(ascii: "z"): + return c + default: return nil + } + } + return nil + } +} + +extension UInt8 { + var isAsciiLetter: Bool { + switch self { + case UInt8(ascii: "A")...UInt8(ascii: "Z"), + UInt8(ascii: "a")...UInt8(ascii: "z"): + return true + default: return false + } + } +} + +let monthMap: [[UInt8]: Int] = [ + Array("Jan".utf8): 1, + Array("Feb".utf8): 2, + Array("Mar".utf8): 3, + Array("Apr".utf8): 4, + Array("May".utf8): 5, + Array("Jun".utf8): 6, + Array("Jul".utf8): 7, + Array("Aug".utf8): 8, + Array("Sep".utf8): 9, + Array("Oct".utf8): 10, + Array("Nov".utf8): 11, + Array("Dec".utf8): 12, +] + +let timezoneOffsetMap: [[UInt8]: Int] = [ + Array("UTC".utf8): 0, + Array("GMT".utf8): 0, + Array("EDT".utf8): -4 * 60, + Array("CDT".utf8): -5 * 60, + Array("MDT".utf8): -6 * 60, + Array("PDT".utf8): -7 * 60, +] diff --git a/Tests/AWSLambdaEventsTests/SNSTests.swift b/Tests/AWSLambdaEventsTests/SNSTests.swift index 0e81ded..7c8368a 100644 --- a/Tests/AWSLambdaEventsTests/SNSTests.swift +++ b/Tests/AWSLambdaEventsTests/SNSTests.swift @@ -72,7 +72,7 @@ class SNSTests: XCTestCase { XCTAssertEqual(record.sns.messageId, "bdb6900e-1ae9-5b4b-b7fc-c681fde222e3") XCTAssertEqual(record.sns.topicArn, "arn:aws:sns:eu-central-1:079477498937:EventSources-SNSTopic-1NHENSE2MQKF5") XCTAssertEqual(record.sns.message, "{\"hello\": \"world\"}") - XCTAssertEqual(record.sns.timestamp, Date(timeIntervalSince1970: 1_578_493_131.203)) + XCTAssertEqual(record.sns.timestamp.timeIntervalSince1970, 1_578_493_131.203, accuracy: 0.001) XCTAssertEqual(record.sns.signatureVersion, "1") XCTAssertEqual( record.sns.signature, diff --git a/Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift b/Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift index b9f1993..f55eb03 100644 --- a/Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift +++ b/Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift @@ -46,8 +46,8 @@ class DateWrapperTests: XCTestCase { XCTAssertEqual(context.codingPath.map(\.stringValue), ["date"]) XCTAssertEqual( - context.debugDescription, - "Expected date to be in ISO8601 date format, but `\(date)` is not in the correct format" + "Expected date to be in ISO8601 date format, but `\(date)` is not in the correct format", + context.debugDescription ) XCTAssertNil(context.underlyingError) } @@ -63,7 +63,7 @@ class DateWrapperTests: XCTestCase { var event: TestEvent? XCTAssertNoThrow(event = try JSONDecoder().decode(TestEvent.self, from: json.data(using: .utf8)!)) - XCTAssertEqual(event?.date, Date(timeIntervalSince1970: 1_585_241_585.123)) + XCTAssertEqual(event?.date.timeIntervalSince1970 ?? 0.0, 1_585_241_585.123, accuracy: 0.001) } func testISO8601WithFractionalSecondsCodingWrapperFailure() { diff --git a/Tests/AWSLambdaEventsTests/Utils/HTTPHeadersTests.swift b/Tests/AWSLambdaEventsTests/Utils/HTTPHeadersTests.swift index dd5a4bc..45c7cc2 100644 --- a/Tests/AWSLambdaEventsTests/Utils/HTTPHeadersTests.swift +++ b/Tests/AWSLambdaEventsTests/Utils/HTTPHeadersTests.swift @@ -16,7 +16,7 @@ import AWSLambdaEvents import XCTest class HTTPHeadersTests: XCTestCase { - func first() throws { + func testFirst() throws { let headers: HTTPHeaders = [ ":method": "GET", "foo": "bar", diff --git a/Tests/AWSLambdaEventsTests/Utils/IteratorTests.swift b/Tests/AWSLambdaEventsTests/Utils/IteratorTests.swift new file mode 100644 index 0000000..c2e682c --- /dev/null +++ b/Tests/AWSLambdaEventsTests/Utils/IteratorTests.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2020 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest + +@testable import AWSLambdaEvents + +final class IteratorProtocolTests: XCTestCase { + func testExpect() { + // Test matching character + var iterator = "abc".utf8.makeIterator() + XCTAssertTrue(iterator.expect(UInt8(ascii: "a"))) + XCTAssertEqual(iterator.next(), UInt8(ascii: "b")) + + // Test non-matching character + iterator = "abc".utf8.makeIterator() + XCTAssertFalse(iterator.expect(UInt8(ascii: "x"))) + } + + func testNextSkippingWhitespace() { + // Test with leading spaces + var iterator = " abc".utf8.makeIterator() + XCTAssertEqual(iterator.nextSkippingWhitespace(), UInt8(ascii: "a")) + + // Test with no spaces + iterator = "abc".utf8.makeIterator() + XCTAssertEqual(iterator.nextSkippingWhitespace(), UInt8(ascii: "a")) + + // Test with only spaces + iterator = " ".utf8.makeIterator() + XCTAssertNil(iterator.nextSkippingWhitespace()) + } + + func testNextAsciiDigit() { + // Test basic digit + var iterator = "123".utf8.makeIterator() + XCTAssertEqual(iterator.nextAsciiDigit(), UInt8(ascii: "1")) + + // Test with leading spaces and skipping whitespace + iterator = " 123".utf8.makeIterator() + XCTAssertEqual(iterator.nextAsciiDigit(skippingWhitespace: true), UInt8(ascii: "1")) + + // Test with leading spaces and not skipping whitespace + iterator = " 123".utf8.makeIterator() + XCTAssertNil(iterator.nextAsciiDigit()) + + // Test with non-digit + iterator = "abc".utf8.makeIterator() + XCTAssertNil(iterator.nextAsciiDigit()) + } + + func testNextAsciiLetter() { + // Test basic letter + var iterator = "abc".utf8.makeIterator() + XCTAssertEqual(iterator.nextAsciiLetter(), UInt8(ascii: "a")) + + // Test with leading spaces and skipping whitespace + iterator = " abc".utf8.makeIterator() + XCTAssertEqual(iterator.nextAsciiLetter(skippingWhitespace: true), UInt8(ascii: "a")) + + // Test with leading spaces and not skipping whitespace + iterator = " abc".utf8.makeIterator() + XCTAssertNil(iterator.nextAsciiLetter()) + + // Test with non-letter + iterator = "123".utf8.makeIterator() + XCTAssertNil(iterator.nextAsciiLetter()) + + // Test with uppercase + iterator = "ABC".utf8.makeIterator() + XCTAssertEqual(iterator.nextAsciiLetter(), UInt8(ascii: "A")) + + // Test with empty string + iterator = "".utf8.makeIterator() + XCTAssertNil(iterator.nextAsciiLetter()) + } +} diff --git a/Tests/AWSLambdaEventsTests/Utils/RFC5322DateParseStrategyTests.swift b/Tests/AWSLambdaEventsTests/Utils/RFC5322DateParseStrategyTests.swift new file mode 100644 index 0000000..52999cb --- /dev/null +++ b/Tests/AWSLambdaEventsTests/Utils/RFC5322DateParseStrategyTests.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftAWSLambdaRuntime open source project +// +// Copyright (c) 2017-2020 Apple Inc. and the SwiftAWSLambdaRuntime project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftAWSLambdaRuntime project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest + +@testable import AWSLambdaEvents + +class RFC5322DateParseStrategyTests: XCTestCase { + let strategy = RFC5322DateParseStrategy(calendar: Calendar(identifier: .gregorian)) + + func testSuccess() { + let input = "Fri, 26 Jun 2020 03:04:03 -0500 (CDT)" + let date = try? strategy.parse(input) + XCTAssertNotNil(date) + XCTAssertEqual(date?.description, "2020-06-26 08:04:03 +0000") + } + + func testSomeRandomDates() throws { + let dates = [ + ("1 Jan 2020 00:00:00 +0000", "2020-01-01 00:00:00 +0000"), + ("15 Feb 2020 01:02:03 GMT", "2020-02-15 01:02:03 +0000"), + ("30 Mar 2020 02:03:04 UTC", "2020-03-30 02:03:04 +0000"), + ("15 Apr 2020 03:04:05 -0500 (CDT)", "2020-04-15 08:04:05 +0000"), + ("1 Jun 2020 04:05:06 -0600 (EDT)", "2020-06-01 10:05:06 +0000"), + ("15 Jul 2020 05:06:07 -0700 (PDT)", "2020-07-15 12:06:07 +0000"), + ("31 Aug 2020 12:07:08 -0200 (CEST)", "2020-08-31 14:07:08 +0000"), + ("15 Sep 2020 07:08:09 -0900 (AKST)", "2020-09-15 16:08:09 +0000"), + ("30 Oct 2020 08:09:10 -1000 (HST)", "2020-10-30 18:09:10 +0000"), + ("15 Nov 2020 09:10:11 -1100 (AKST)", "2020-11-15 20:10:11 +0000"), + ("30 Dec 2020 10:11:12 -1200 (HST)", "2020-12-30 22:11:12 +0000"), + ] + + for (input, expected) in dates { + let date = try strategy.parse(input) + XCTAssertEqual(date.description, expected) + } + } + + func testWithLeadingDayName() throws { + let input = "Fri, 26 Jun 2020 03:04:03 -0500 (CDT)" + let date = try strategy.parse(input) + XCTAssertEqual("2020-06-26 08:04:03 +0000", date.description) + } + + func testEmptyString() { + let input = "" + XCTAssertThrowsError(try strategy.parse(input)) + } + + func testWithInvalidDay() { + let input = "Fri, 36 Jun 2020 03:04:03 -0500 (CDT)" + XCTAssertThrowsError(try strategy.parse(input)) + } + + func testWithInvalidMonth() { + let input = "Fri, 26 XXX 2020 03:04:03 -0500 (CDT)" + XCTAssertThrowsError(try strategy.parse(input)) + } + + func testWithInvalidHour() { + let input = "Fri, 26 Jun 2020 48:04:03 -0500 (CDT)" + XCTAssertThrowsError(try strategy.parse(input)) + } + + func testWithInvalidMinute() { + let input = "Fri, 26 Jun 2020 03:64:03 -0500 (CDT)" + XCTAssertThrowsError(try strategy.parse(input)) + } + + func testWithInvalidSecond() { + let input = "Fri, 26 Jun 2020 03:04:64 -0500 (CDT)" + XCTAssertThrowsError(try strategy.parse(input)) + } + + func testWithGMT() throws { + let input = "Fri, 26 Jun 2020 03:04:03 GMT" + let date = try strategy.parse(input) + XCTAssertEqual("2020-06-26 03:04:03 +0000", date.description) + } + + func testWithUTC() throws { + let input = "Fri, 26 Jun 2020 03:04:03 UTC" + let date = try strategy.parse(input) + XCTAssertEqual("2020-06-26 03:04:03 +0000", date.description) + } + + func testPartialInput() { + let input = "Fri, 26 Jun 20" + XCTAssertThrowsError(try strategy.parse(input)) + } + + func testPartialTimezone() { + let input = "Fri, 26 Jun 2020 03:04:03 -05" + XCTAssertThrowsError(try strategy.parse(input)) + } + + func testInvalidTimezone() { + let input = "Fri, 26 Jun 2020 03:04:03 -05CDT (CDT)" + XCTAssertThrowsError(try strategy.parse(input)) + } +}