Skip to content

Use only FoundationEssentials when possible #81

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Dec 19, 2024
311 changes: 281 additions & 30 deletions Sources/AWSLambdaEvents/Utils/DateWrappers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
//
//===----------------------------------------------------------------------===//

#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif

@propertyWrapper
public struct ISO8601Coding: Decodable, Sendable {
Expand All @@ -25,14 +29,24 @@ 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 {

struct InvalidDateError: Error {}

do {
if #available(macOS 12.0, *) {
self.wrappedValue = try Date(dateString, strategy: .iso8601)
} else if let date = Self.dateFormatter.date(from: dateString) {
self.wrappedValue = date
} else {
throw InvalidDateError()
}
} catch {
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 var dateFormatter: DateFormatter {
Expand All @@ -55,14 +69,24 @@ 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 {

struct InvalidDateError: Error {}

do {
if #available(macOS 12.0, *) {
self.wrappedValue = try Date(dateString, strategy: Self.iso8601WithFractionalSeconds)
} else if let date = Self.dateFormatter.date(from: dateString) {
self.wrappedValue = date
} else {
throw InvalidDateError()
}
} catch {
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 var dateFormatter: DateFormatter {
Expand All @@ -72,6 +96,11 @@ public struct ISO8601WithFractionalSecondsCoding: Decodable, Sendable {
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
return formatter
}

@available(macOS 12.0, *)
private static var iso8601WithFractionalSeconds: Date.ISO8601FormatStyle {
Date.ISO8601FormatStyle(includingFractionalSeconds: true)
}
}

@propertyWrapper
Expand All @@ -84,34 +113,256 @@ 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..<bracket].trimmingCharacters(in: .whitespaces))
let string = try container.decode(String.self)

do {
if #available(macOS 12.0, *) {
self.wrappedValue = try Date(string, strategy: RFC5322DateParseStrategy())
} else {
self.wrappedValue = try RFC5322DateParseStrategy().parse(string)
}
} catch {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription:
"Expected date to be in RFC5322 date-time format, but `\(string)` is not in the correct format"
)
}
for formatter in Self.dateFormatters {
if let date = formatter.date(from: string) {
self.wrappedValue = date
return
}
}

struct RFC5322DateParsingError: Error {}

struct RFC5322DateParseStrategy {
func parse(_ input: String) throws -> 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..<endIndex]

let asciiDigits = UInt8(ascii: "0")...UInt8(ascii: "9")

return s.withUTF8 { buffer -> DateComponents? in
func parseDay(_ it: inout UnsafeBufferPointer<UInt8>.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 }

if asciiDigits.contains(second) {
return Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0"))
} else {
return Int(first - UInt8(ascii: "0"))
}
}

func skipWhitespace(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> UInt8? {
while let c = it.next() {
if c != UInt8(ascii: " ") {
return c
}
}
return nil
}

func parseMonth(_ it: inout UnsafeBufferPointer<UInt8>.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 }
return monthMap[[first, second, third]]
}

func parseYear(_ it: inout UnsafeBufferPointer<UInt8>.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<UInt8>.Iterator) -> Int? {
let first = it.nextAsciiDigit(skippingWhitespace: true)
let second = it.nextAsciiDigit()
guard let first = first, let second = second else { return nil }
return Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0"))
}

func parseMinute(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
let first = it.nextAsciiDigit(skippingWhitespace: true)
let second = it.nextAsciiDigit()
guard let first = first, let second = second else { return nil }
return Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0"))
}

func parseSecond(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
let first = it.nextAsciiDigit(skippingWhitespace: true)
let second = it.nextAsciiDigit()
guard let first = first, let second = second else { return nil }
return Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0"))
}

func parseTimezone(_ it: inout UnsafeBufferPointer<UInt8>.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: Calendar(identifier: .gregorian),
timeZone: TimeZone(secondsFromGMT: timezoneOffsetMinutes * 60),
year: year,
month: month,
day: day,
hour: hour,
minute: minute,
second: second
)
}
}
}

@available(macOS 12.0, *)
extension RFC5322DateParseStrategy: ParseStrategy {}

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
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription:
"Expected date to be in RFC5322 date-time format, but `\(string)` is not in the correct format"
)
}

private static var dateFormatters: [DateFormatter] {
// rfc5322 dates received in SES mails sometimes do not include the day, so need two dateformatters
// one with a day and one without
let formatterWithDay = DateFormatter()
formatterWithDay.dateFormat = "EEE, d MMM yyy HH:mm:ss z"
formatterWithDay.locale = Locale(identifier: "en_US_POSIX")
let formatterWithoutDay = DateFormatter()
formatterWithoutDay.dateFormat = "d MMM yyy HH:mm:ss z"
formatterWithoutDay.locale = Locale(identifier: "en_US_POSIX")
return [formatterWithDay, formatterWithoutDay]
}
}

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,
]
2 changes: 1 addition & 1 deletion Tests/AWSLambdaEventsTests/SNSTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion Tests/AWSLambdaEventsTests/Utils/HTTPHeadersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import AWSLambdaEvents
import XCTest

class HTTPHeadersTests: XCTestCase {
func first() throws {
func testFirst() throws {
let headers: HTTPHeaders = [
":method": "GET",
"foo": "bar",
Expand Down