Skip to content

Commit 904af3d

Browse files
committed
use FoundationEssentials when possible
1 parent 334d865 commit 904af3d

File tree

4 files changed

+285
-35
lines changed

4 files changed

+285
-35
lines changed

Diff for: Sources/AWSLambdaEvents/Utils/DateWrappers.swift

+280-30
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
//
1313
//===----------------------------------------------------------------------===//
1414

15+
#if canImport(FoundationEssentials)
16+
import FoundationEssentials
17+
#else
1518
import Foundation
19+
#endif
1620

1721
@propertyWrapper
1822
public struct ISO8601Coding: Decodable, Sendable {
@@ -25,14 +29,24 @@ public struct ISO8601Coding: Decodable, Sendable {
2529
public init(from decoder: Decoder) throws {
2630
let container = try decoder.singleValueContainer()
2731
let dateString = try container.decode(String.self)
28-
guard let date = Self.dateFormatter.date(from: dateString) else {
32+
33+
struct InvalidDateError: Error {}
34+
35+
do {
36+
if #available(macOS 12.0, *) {
37+
self.wrappedValue = try Date(dateString, strategy: .iso8601)
38+
} else if let date = Self.dateFormatter.date(from: dateString) {
39+
self.wrappedValue = date
40+
} else {
41+
throw InvalidDateError()
42+
}
43+
} catch {
2944
throw DecodingError.dataCorruptedError(
3045
in: container,
3146
debugDescription:
3247
"Expected date to be in ISO8601 date format, but `\(dateString)` is not in the correct format"
3348
)
3449
}
35-
self.wrappedValue = date
3650
}
3751

3852
private static var dateFormatter: DateFormatter {
@@ -55,14 +69,24 @@ public struct ISO8601WithFractionalSecondsCoding: Decodable, Sendable {
5569
public init(from decoder: Decoder) throws {
5670
let container = try decoder.singleValueContainer()
5771
let dateString = try container.decode(String.self)
58-
guard let date = Self.dateFormatter.date(from: dateString) else {
72+
73+
struct InvalidDateError: Error {}
74+
75+
do {
76+
if #available(macOS 12.0, *) {
77+
self.wrappedValue = try Date(dateString, strategy: Self.iso8601WithFractionalSeconds)
78+
} else if let date = Self.dateFormatter.date(from: dateString) {
79+
self.wrappedValue = date
80+
} else {
81+
throw InvalidDateError()
82+
}
83+
} catch {
5984
throw DecodingError.dataCorruptedError(
6085
in: container,
6186
debugDescription:
6287
"Expected date to be in ISO8601 date format with fractional seconds, but `\(dateString)` is not in the correct format"
6388
)
6489
}
65-
self.wrappedValue = date
6690
}
6791

6892
private static var dateFormatter: DateFormatter {
@@ -72,6 +96,11 @@ public struct ISO8601WithFractionalSecondsCoding: Decodable, Sendable {
7296
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
7397
return formatter
7498
}
99+
100+
@available(macOS 12.0, *)
101+
private static var iso8601WithFractionalSeconds: Date.ISO8601FormatStyle {
102+
Date.ISO8601FormatStyle(includingFractionalSeconds: true)
103+
}
75104
}
76105

77106
@propertyWrapper
@@ -84,34 +113,255 @@ public struct RFC5322DateTimeCoding: Decodable, Sendable {
84113

85114
public init(from decoder: Decoder) throws {
86115
let container = try decoder.singleValueContainer()
87-
var string = try container.decode(String.self)
88-
// RFC5322 dates sometimes have the alphabetic version of the timezone in brackets after the numeric version. The date formatter
89-
// fails to parse this so we need to remove this before parsing.
90-
if let bracket = string.firstIndex(of: "(") {
91-
string = String(string[string.startIndex..<bracket].trimmingCharacters(in: .whitespaces))
116+
let string = try container.decode(String.self)
117+
118+
do {
119+
if #available(macOS 12.0, *) {
120+
self.wrappedValue = try Date(string, strategy: RFC5322DateStrategy())
121+
} else {
122+
self.wrappedValue = try RFC5322DateStrategy().parse(string)
123+
}
124+
} catch {
125+
throw DecodingError.dataCorruptedError(
126+
in: container,
127+
debugDescription:
128+
"Expected date to be in RFC5322 date-time format, but `\(string)` is not in the correct format"
129+
)
92130
}
93-
for formatter in Self.dateFormatters {
94-
if let date = formatter.date(from: string) {
95-
self.wrappedValue = date
96-
return
131+
}
132+
}
133+
134+
struct RFC5322DateParsingError: Error {}
135+
136+
struct RFC5322DateStrategy {
137+
func parse(_ input: String) throws -> Date {
138+
guard let components = self.components(from: input) else {
139+
throw RFC5322DateParsingError()
140+
}
141+
guard let date = components.date else {
142+
throw RFC5322DateParsingError()
143+
}
144+
return date
145+
}
146+
147+
func components(from input: String) -> DateComponents? {
148+
var endIndex = input.endIndex
149+
// If the date string has a timezone in brackets, we need to remove it before parsing.
150+
if let bracket = input.firstIndex(of: "(") {
151+
endIndex = bracket
152+
}
153+
var s = input[input.startIndex..<endIndex]
154+
155+
let asciiNumbers = UInt8(ascii: "0")...UInt8(ascii: "9")
156+
157+
return s.withUTF8 { buffer -> DateComponents? in
158+
func parseDay(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
159+
let first = it.next()
160+
let second = it.next()
161+
guard let first = first, let second = second else { return nil }
162+
163+
guard asciiNumbers.contains(first) else { return nil }
164+
165+
if asciiNumbers.contains(second) {
166+
return Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0"))
167+
} else {
168+
return Int(first - UInt8(ascii: "0"))
169+
}
170+
}
171+
172+
func skipWhitespace(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> UInt8? {
173+
while let c = it.next() {
174+
if c != UInt8(ascii: " ") {
175+
return c
176+
}
177+
}
178+
return nil
179+
}
180+
181+
func parseMonth(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
182+
let first = it.nextAsciiLetter(skippingWhitespace: true)
183+
let second = it.nextAsciiLetter()
184+
let third = it.nextAsciiLetter()
185+
guard let first = first, let second = second, let third = third else { return nil }
186+
guard first.isAsciiLetter else { return nil }
187+
return monthMap[[first, second, third]]
188+
}
189+
190+
func parseYear(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
191+
let first = it.nextAsciiNumber(skippingWhitespace: true)
192+
let second = it.nextAsciiNumber()
193+
let third = it.nextAsciiNumber()
194+
let fourth = it.nextAsciiNumber()
195+
guard let first = first,
196+
let second = second,
197+
let third = third,
198+
let fourth = fourth else { return nil }
199+
return Int(first - UInt8(ascii: "0")) * 1000
200+
+ Int(second - UInt8(ascii: "0")) * 100
201+
+ Int(third - UInt8(ascii: "0")) * 10
202+
+ Int(fourth - UInt8(ascii: "0"))
203+
}
204+
205+
func parseHour(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
206+
let first = it.nextAsciiNumber(skippingWhitespace: true)
207+
let second = it.nextAsciiNumber()
208+
guard let first = first, let second = second else { return nil }
209+
return Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0"))
210+
}
211+
212+
func parseMinute(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
213+
let first = it.nextAsciiNumber(skippingWhitespace: true)
214+
let second = it.nextAsciiNumber()
215+
guard let first = first, let second = second else { return nil }
216+
return Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0"))
97217
}
218+
219+
func parseSecond(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
220+
let first = it.nextAsciiNumber(skippingWhitespace: true)
221+
let second = it.nextAsciiNumber()
222+
guard let first = first, let second = second else { return nil }
223+
return Int(first - UInt8(ascii: "0")) * 10 + Int(second - UInt8(ascii: "0"))
224+
}
225+
226+
func parseTimezone(_ it: inout UnsafeBufferPointer<UInt8>.Iterator) -> Int? {
227+
let plusMinus = it.nextSkippingWhitespace()
228+
if let plusMinus, plusMinus == UInt8(ascii: "+") || plusMinus == UInt8(ascii: "-") {
229+
let hour = parseHour(&it)
230+
let minute = parseMinute(&it)
231+
guard let hour = hour, let minute = minute else { return nil }
232+
return (hour * 60 + minute) * (plusMinus == UInt8(ascii: "+") ? 1 : -1)
233+
} else if let first = plusMinus {
234+
let second = it.nextAsciiLetter()
235+
let third = it.nextAsciiLetter()
236+
237+
guard let second = second, let third = third else { return nil }
238+
let abbr = [first, second, third]
239+
return timezoneOffsetMap[abbr]
240+
}
241+
242+
return nil
243+
}
244+
245+
var it = buffer.makeIterator()
246+
247+
// if the 4th character is a comma, then we have a day of the week
248+
guard buffer.count > 5 else { return nil }
249+
250+
if buffer[3] == UInt8(ascii: ",") {
251+
for _ in 0..<5 {
252+
_ = it.next()
253+
}
254+
}
255+
256+
guard let day = parseDay(&it) else { return nil }
257+
guard let month = parseMonth(&it) else { return nil }
258+
guard let year = parseYear(&it) else { return nil }
259+
260+
guard let hour = parseHour(&it) else { return nil }
261+
guard it.expect(UInt8(ascii: ":")) else { return nil }
262+
guard let minute = parseMinute(&it) else { return nil }
263+
guard it.expect(UInt8(ascii: ":")) else { return nil }
264+
guard let second = parseSecond(&it) else { return nil }
265+
266+
guard let timezoneOffsetMinutes = parseTimezone(&it) else { return nil }
267+
268+
return DateComponents(
269+
calendar: Calendar(identifier: .gregorian),
270+
timeZone: TimeZone(secondsFromGMT: timezoneOffsetMinutes * 60),
271+
year: year,
272+
month: month,
273+
day: day,
274+
hour: hour,
275+
minute: minute,
276+
second: second
277+
)
278+
}
279+
}
280+
}
281+
282+
@available(macOS 12.0, *)
283+
extension RFC5322DateStrategy: ParseStrategy {}
284+
285+
extension IteratorProtocol where Self.Element == UInt8 {
286+
mutating func expect(_ expected: UInt8) -> Bool {
287+
guard self.next() == expected else { return false }
288+
return true
289+
}
290+
291+
mutating func nextSkippingWhitespace() -> UInt8? {
292+
while let c = self.next() {
293+
if c != UInt8(ascii: " ") {
294+
return c
295+
}
296+
}
297+
return nil
298+
}
299+
300+
mutating func nextAsciiNumber(skippingWhitespace: Bool = false) -> UInt8? {
301+
while let c = self.next() {
302+
if skippingWhitespace {
303+
if c == UInt8(ascii: " ") {
304+
continue
305+
}
306+
}
307+
switch c {
308+
case UInt8(ascii: "0")...UInt8(ascii: "9"): return c
309+
default: return nil
310+
}
311+
}
312+
return nil
313+
}
314+
315+
mutating func nextAsciiLetter(skippingWhitespace: Bool = false) -> UInt8? {
316+
while let c = self.next() {
317+
if skippingWhitespace {
318+
if c == UInt8(ascii: " ") {
319+
continue
320+
}
321+
}
322+
323+
switch c {
324+
case UInt8(ascii: "A")...UInt8(ascii: "Z"),
325+
UInt8(ascii: "a")...UInt8(ascii: "z"):
326+
return c
327+
default: return nil
328+
}
329+
}
330+
return nil
331+
}
332+
}
333+
334+
extension UInt8 {
335+
var isAsciiLetter: Bool {
336+
switch self {
337+
case UInt8(ascii: "A")...UInt8(ascii: "Z"),
338+
UInt8(ascii: "a")...UInt8(ascii: "z"):
339+
return true
340+
default: return false
98341
}
99-
throw DecodingError.dataCorruptedError(
100-
in: container,
101-
debugDescription:
102-
"Expected date to be in RFC5322 date-time format, but `\(string)` is not in the correct format"
103-
)
104-
}
105-
106-
private static var dateFormatters: [DateFormatter] {
107-
// rfc5322 dates received in SES mails sometimes do not include the day, so need two dateformatters
108-
// one with a day and one without
109-
let formatterWithDay = DateFormatter()
110-
formatterWithDay.dateFormat = "EEE, d MMM yyy HH:mm:ss z"
111-
formatterWithDay.locale = Locale(identifier: "en_US_POSIX")
112-
let formatterWithoutDay = DateFormatter()
113-
formatterWithoutDay.dateFormat = "d MMM yyy HH:mm:ss z"
114-
formatterWithoutDay.locale = Locale(identifier: "en_US_POSIX")
115-
return [formatterWithDay, formatterWithoutDay]
116342
}
117343
}
344+
345+
let monthMap: [[UInt8]: Int] = [
346+
Array("Jan".utf8): 1,
347+
Array("Feb".utf8): 2,
348+
Array("Mar".utf8): 3,
349+
Array("Apr".utf8): 4,
350+
Array("May".utf8): 5,
351+
Array("Jun".utf8): 6,
352+
Array("Jul".utf8): 7,
353+
Array("Aug".utf8): 8,
354+
Array("Sep".utf8): 9,
355+
Array("Oct".utf8): 10,
356+
Array("Nov".utf8): 11,
357+
Array("Dec".utf8): 12,
358+
]
359+
360+
let timezoneOffsetMap: [[UInt8]: Int] = [
361+
Array("UTC".utf8): 0,
362+
Array("GMT".utf8): 0,
363+
Array("EDT".utf8): -4 * 60,
364+
Array("CDT".utf8): -5 * 60,
365+
Array("MDT".utf8): -6 * 60,
366+
Array("PDT".utf8): -7 * 60,
367+
]

Diff for: Tests/AWSLambdaEventsTests/SNSTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class SNSTests: XCTestCase {
7272
XCTAssertEqual(record.sns.messageId, "bdb6900e-1ae9-5b4b-b7fc-c681fde222e3")
7373
XCTAssertEqual(record.sns.topicArn, "arn:aws:sns:eu-central-1:079477498937:EventSources-SNSTopic-1NHENSE2MQKF5")
7474
XCTAssertEqual(record.sns.message, "{\"hello\": \"world\"}")
75-
XCTAssertEqual(record.sns.timestamp, Date(timeIntervalSince1970: 1_578_493_131.203))
75+
XCTAssertEqual(record.sns.timestamp.timeIntervalSince1970, 1_578_493_131.203, accuracy: 0.001)
7676
XCTAssertEqual(record.sns.signatureVersion, "1")
7777
XCTAssertEqual(
7878
record.sns.signature,

Diff for: Tests/AWSLambdaEventsTests/Utils/DateWrapperTests.swift

+3-3
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ class DateWrapperTests: XCTestCase {
4646

4747
XCTAssertEqual(context.codingPath.map(\.stringValue), ["date"])
4848
XCTAssertEqual(
49-
context.debugDescription,
50-
"Expected date to be in ISO8601 date format, but `\(date)` is not in the correct format"
49+
"Expected date to be in ISO8601 date format, but `\(date)` is not in the correct format",
50+
context.debugDescription
5151
)
5252
XCTAssertNil(context.underlyingError)
5353
}
@@ -63,7 +63,7 @@ class DateWrapperTests: XCTestCase {
6363
var event: TestEvent?
6464
XCTAssertNoThrow(event = try JSONDecoder().decode(TestEvent.self, from: json.data(using: .utf8)!))
6565

66-
XCTAssertEqual(event?.date, Date(timeIntervalSince1970: 1_585_241_585.123))
66+
XCTAssertEqual(event?.date.timeIntervalSince1970 ?? 0.0, 1_585_241_585.123, accuracy: 0.001)
6767
}
6868

6969
func testISO8601WithFractionalSecondsCodingWrapperFailure() {

Diff for: Tests/AWSLambdaEventsTests/Utils/HTTPHeadersTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import AWSLambdaEvents
1616
import XCTest
1717

1818
class HTTPHeadersTests: XCTestCase {
19-
func first() throws {
19+
func testFirst() throws {
2020
let headers: HTTPHeaders = [
2121
":method": "GET",
2222
"foo": "bar",

0 commit comments

Comments
 (0)