diff --git a/Sources/AWSLambdaEvents/AWSRegion.swift b/Sources/AWSLambdaEvents/AWSRegion.swift new file mode 100644 index 00000000..d6bd180b --- /dev/null +++ b/Sources/AWSLambdaEvents/AWSRegion.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +// list all available regions using aws cli: +// $ aws ssm get-parameters-by-path --path /aws/service/global-infrastructure/services/lambda/regions --output json + +/// Enumeration of the AWS Regions. +public enum AWSRegion: String, Codable { + case ap_northeast_1 = "ap-northeast-1" + case ap_northeast_2 = "ap-northeast-2" + case ap_east_1 = "ap-east-1" + case ap_southeast_1 = "ap-southeast-1" + case ap_southeast_2 = "ap-southeast-2" + case ap_south_1 = "ap-south-1" + + case cn_north_1 = "cn-north-1" + case cn_northwest_1 = "cn-northwest-1" + + case eu_north_1 = "eu-north-1" + case eu_west_1 = "eu-west-1" + case eu_west_2 = "eu-west-2" + case eu_west_3 = "eu-west-3" + case eu_central_1 = "eu-central-1" + + case us_east_1 = "us-east-1" + case us_east_2 = "us-east-2" + case us_west_1 = "us-west-1" + case us_west_2 = "us-west-2" + case us_gov_east_1 = "us-gov-east-1" + case us_gov_west_1 = "us-gov-west-1" + + case ca_central_1 = "ca-central-1" + case sa_east_1 = "sa-east-1" + case me_south_1 = "me-south-1" +} diff --git a/Sources/AWSLambdaEvents/Cloudwatch.swift b/Sources/AWSLambdaEvents/Cloudwatch.swift new file mode 100644 index 00000000..0071756e --- /dev/null +++ b/Sources/AWSLambdaEvents/Cloudwatch.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// +// 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 struct Foundation.Date + +/// EventBridge has the same payloads/notification types as CloudWatch +typealias EventBridge = Cloudwatch + +public protocol CloudwatchDetail: Decodable { + static var name: String { get } +} + +public extension CloudwatchDetail { + var detailType: String { + Self.name + } +} + +public enum Cloudwatch { + /// CloudWatch.Event is the outer structure of an event sent via CloudWatch Events. + /// + /// **NOTE**: For examples of events that come via CloudWatch Events, see + /// https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/EventTypes.html + /// https://docs.aws.amazon.com/eventbridge/latest/userguide/event-types.html + public struct Event: Decodable { + public let id: String + public let source: String + public let accountId: String + public let time: Date + public let region: AWSRegion + public let resources: [String] + public let detail: Detail + + enum CodingKeys: String, CodingKey { + case id + case source + case accountId = "account" + case time + case region + case resources + case detailType = "detail-type" + case detail + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(String.self, forKey: .id) + self.source = try container.decode(String.self, forKey: .source) + self.accountId = try container.decode(String.self, forKey: .accountId) + self.time = (try container.decode(ISO8601Coding.self, forKey: .time)).wrappedValue + self.region = try container.decode(AWSRegion.self, forKey: .region) + self.resources = try container.decode([String].self, forKey: .resources) + + let detailType = try container.decode(String.self, forKey: .detailType) + guard detailType.lowercased() == Detail.name.lowercased() else { + throw PayloadTypeMismatch(name: detailType, type: Detail.self) + } + + self.detail = try container.decode(Detail.self, forKey: .detail) + } + } + + // MARK: - Common Event Types + + public typealias ScheduledEvent = Event + public struct Scheduled: CloudwatchDetail { + public static let name = "Scheduled Event" + } + + public enum EC2 { + public typealias InstanceStateChangeNotificationEvent = Event + public struct InstanceStateChangeNotification: CloudwatchDetail { + public static let name = "EC2 Instance State-change Notification" + + public enum State: String, Codable { + case running + case shuttingDown = "shutting-down" + case stopped + case stopping + case terminated + } + + public let instanceId: String + public let state: State + + enum CodingKeys: String, CodingKey { + case instanceId = "instance-id" + case state + } + } + + public typealias SpotInstanceInterruptionNoticeEvent = Event + public struct SpotInstanceInterruptionNotice: CloudwatchDetail { + public static let name = "EC2 Spot Instance Interruption Warning" + + public enum Action: String, Codable { + case hibernate + case stop + case terminate + } + + public let instanceId: String + public let action: Action + + enum CodingKeys: String, CodingKey { + case instanceId = "instance-id" + case action = "instance-action" + } + } + } + + struct PayloadTypeMismatch: Error { + let name: String + let type: Any + } +} diff --git a/Sources/AWSLambdaEvents/DateWrappers.swift b/Sources/AWSLambdaEvents/DateWrappers.swift index df9985fe..4e24946c 100644 --- a/Sources/AWSLambdaEvents/DateWrappers.swift +++ b/Sources/AWSLambdaEvents/DateWrappers.swift @@ -15,6 +15,27 @@ import struct Foundation.Date import class Foundation.ISO8601DateFormatter +@propertyWrapper +public struct ISO8601Coding: Decodable { + public let wrappedValue: Date + + public init(wrappedValue: Date) { + self.wrappedValue = wrappedValue + } + + 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 { + throw DecodingError.dataCorruptedError(in: container, debugDescription: + "Expected date to be in iso8601 date format, but `\(dateString)` does not forfill format") + } + self.wrappedValue = date + } + + private static let dateFormatter = ISO8601DateFormatter() +} + @propertyWrapper public struct ISO8601WithFractionalSecondsCoding: Decodable { public let wrappedValue: Date @@ -28,7 +49,7 @@ public struct ISO8601WithFractionalSecondsCoding: Decodable { let dateString = try container.decode(String.self) guard let date = Self.dateFormatter.date(from: dateString) else { throw DecodingError.dataCorruptedError(in: container, debugDescription: - "Expected date to be in iso8601 date format with fractional seconds, but `\(dateString) does not forfill format`") + "Expected date to be in iso8601 date format with fractional seconds, but `\(dateString)` does not forfill format") } self.wrappedValue = date } diff --git a/Sources/AWSLambdaEvents/S3.swift b/Sources/AWSLambdaEvents/S3.swift index bc9c4f6d..eab77fed 100644 --- a/Sources/AWSLambdaEvents/S3.swift +++ b/Sources/AWSLambdaEvents/S3.swift @@ -20,7 +20,7 @@ public enum S3 { public struct Record: Decodable { public let eventVersion: String public let eventSource: String - public let awsRegion: String + public let awsRegion: AWSRegion @ISO8601WithFractionalSecondsCoding public var eventTime: Date diff --git a/Tests/AWSLambdaEventsTests/CloudwatchTests.swift b/Tests/AWSLambdaEventsTests/CloudwatchTests.swift new file mode 100644 index 00000000..93a677c5 --- /dev/null +++ b/Tests/AWSLambdaEventsTests/CloudwatchTests.swift @@ -0,0 +1,137 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@testable import AWSLambdaEvents +import XCTest + +class CloudwatchTests: XCTestCase { + static func eventPayload(type: String, details: String) -> String { + """ + { + "id": "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c", + "detail-type": "\(type)", + "source": "aws.events", + "account": "123456789012", + "time": "1970-01-01T00:00:00Z", + "region": "us-east-1", + "resources": [ + "arn:aws:events:us-east-1:123456789012:rule/ExampleRule" + ], + "detail": \(details) + } + """ + } + + func testScheduledEventFromJSON() { + let payload = CloudwatchTests.eventPayload(type: Cloudwatch.Scheduled.name, details: "{}") + let data = payload.data(using: .utf8)! + var maybeEvent: Cloudwatch.ScheduledEvent? + XCTAssertNoThrow(maybeEvent = try JSONDecoder().decode(Cloudwatch.ScheduledEvent.self, from: data)) + + guard let event = maybeEvent else { + return XCTFail("Expected to have an event") + } + + XCTAssertEqual(event.id, "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c") + XCTAssertEqual(event.source, "aws.events") + XCTAssertEqual(event.accountId, "123456789012") + XCTAssertEqual(event.time, Date(timeIntervalSince1970: 0)) + XCTAssertEqual(event.region, .us_east_1) + XCTAssertEqual(event.resources, ["arn:aws:events:us-east-1:123456789012:rule/ExampleRule"]) + } + + func testEC2InstanceStateChangeNotificationEventFromJSON() { + let payload = CloudwatchTests.eventPayload(type: Cloudwatch.EC2.InstanceStateChangeNotification.name, + details: "{ \"instance-id\": \"0\", \"state\": \"stopping\" }") + let data = payload.data(using: .utf8)! + var maybeEvent: Cloudwatch.EC2.InstanceStateChangeNotificationEvent? + XCTAssertNoThrow(maybeEvent = try JSONDecoder().decode(Cloudwatch.EC2.InstanceStateChangeNotificationEvent.self, from: data)) + + guard let event = maybeEvent else { + return XCTFail("Expected to have an event") + } + + XCTAssertEqual(event.id, "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c") + XCTAssertEqual(event.source, "aws.events") + XCTAssertEqual(event.accountId, "123456789012") + XCTAssertEqual(event.time, Date(timeIntervalSince1970: 0)) + XCTAssertEqual(event.region, .us_east_1) + XCTAssertEqual(event.resources, ["arn:aws:events:us-east-1:123456789012:rule/ExampleRule"]) + XCTAssertEqual(event.detail.instanceId, "0") + XCTAssertEqual(event.detail.state, .stopping) + } + + func testEC2SpotInstanceInterruptionNoticeEventFromJSON() { + let payload = CloudwatchTests.eventPayload(type: Cloudwatch.EC2.SpotInstanceInterruptionNotice.name, + details: "{ \"instance-id\": \"0\", \"instance-action\": \"terminate\" }") + let data = payload.data(using: .utf8)! + var maybeEvent: Cloudwatch.EC2.SpotInstanceInterruptionNoticeEvent? + XCTAssertNoThrow(maybeEvent = try JSONDecoder().decode(Cloudwatch.EC2.SpotInstanceInterruptionNoticeEvent.self, from: data)) + + guard let event = maybeEvent else { + return XCTFail("Expected to have an event") + } + + XCTAssertEqual(event.id, "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c") + XCTAssertEqual(event.source, "aws.events") + XCTAssertEqual(event.accountId, "123456789012") + XCTAssertEqual(event.time, Date(timeIntervalSince1970: 0)) + XCTAssertEqual(event.region, .us_east_1) + XCTAssertEqual(event.resources, ["arn:aws:events:us-east-1:123456789012:rule/ExampleRule"]) + XCTAssertEqual(event.detail.instanceId, "0") + XCTAssertEqual(event.detail.action, .terminate) + } + + func testCustomEventFromJSON() { + struct Custom: CloudwatchDetail { + public static let name = "Custom" + + let name: String + } + + let payload = CloudwatchTests.eventPayload(type: Custom.name, details: "{ \"name\": \"foo\" }") + let data = payload.data(using: .utf8)! + var maybeEvent: Cloudwatch.Event? + XCTAssertNoThrow(maybeEvent = try JSONDecoder().decode(Cloudwatch.Event.self, from: data)) + + guard let event = maybeEvent else { + return XCTFail("Expected to have an event") + } + + XCTAssertEqual(event.id, "cdc73f9d-aea9-11e3-9d5a-835b769c0d9c") + XCTAssertEqual(event.source, "aws.events") + XCTAssertEqual(event.accountId, "123456789012") + XCTAssertEqual(event.time, Date(timeIntervalSince1970: 0)) + XCTAssertEqual(event.region, .us_east_1) + XCTAssertEqual(event.resources, ["arn:aws:events:us-east-1:123456789012:rule/ExampleRule"]) + XCTAssertEqual(event.detail.name, "foo") + } + + func testUnregistredType() { + let payload = CloudwatchTests.eventPayload(type: UUID().uuidString, details: "{}") + let data = payload.data(using: .utf8)! + XCTAssertThrowsError(try JSONDecoder().decode(Cloudwatch.ScheduledEvent.self, from: data)) { error in + XCTAssert(error is Cloudwatch.PayloadTypeMismatch, "expected PayloadTypeMismatch but received \(error)") + } + } + + func testTypeMismatch() { + let payload = CloudwatchTests.eventPayload(type: Cloudwatch.EC2.InstanceStateChangeNotification.name, + details: "{ \"instance-id\": \"0\", \"state\": \"stopping\" }") + let data = payload.data(using: .utf8)! + XCTAssertThrowsError(try JSONDecoder().decode(Cloudwatch.ScheduledEvent.self, from: data)) { error in + XCTAssert(error is Cloudwatch.PayloadTypeMismatch, "expected PayloadTypeMismatch but received \(error)") + } + } +} diff --git a/Tests/AWSLambdaEventsTests/DateWrapperTests.swift b/Tests/AWSLambdaEventsTests/DateWrapperTests.swift new file mode 100644 index 00000000..b55e4bcb --- /dev/null +++ b/Tests/AWSLambdaEventsTests/DateWrapperTests.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@testable import AWSLambdaEvents +import XCTest + +class DateWrapperTests: XCTestCase { + func testISO8601CodingWrapperSuccess() { + struct TestEvent: Decodable { + @ISO8601Coding + var date: Date + } + + let json = #"{"date":"2020-03-26T16:53:05Z"}"# + var event: TestEvent? + XCTAssertNoThrow(event = try JSONDecoder().decode(TestEvent.self, from: json.data(using: .utf8)!)) + + XCTAssertEqual(event?.date, Date(timeIntervalSince1970: 1_585_241_585)) + } + + func testISO8601CodingWrapperFailure() { + struct TestEvent: Decodable { + @ISO8601Coding + var date: Date + } + + let date = "2020-03-26T16:53:05" // missing Z at end + let json = #"{"date":"\#(date)"}"# + XCTAssertThrowsError(_ = try JSONDecoder().decode(TestEvent.self, from: json.data(using: .utf8)!)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Unexpected error: \(error)"); return + } + + XCTAssertEqual(context.codingPath.compactMap { $0.stringValue }, ["date"]) + XCTAssertEqual(context.debugDescription, "Expected date to be in iso8601 date format, but `\(date)` does not forfill format") + XCTAssertNil(context.underlyingError) + } + } + + func testISO8601WithFractionalSecondsCodingWrapperSuccess() { + struct TestEvent: Decodable { + @ISO8601WithFractionalSecondsCoding + var date: Date + } + + let json = #"{"date":"2020-03-26T16:53:05.123Z"}"# + 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)) + } + + func testISO8601WithFractionalSecondsCodingWrapperFailure() { + struct TestEvent: Decodable { + @ISO8601WithFractionalSecondsCoding + var date: Date + } + + let date = "2020-03-26T16:53:05Z" // missing fractional seconds + let json = #"{"date":"\#(date)"}"# + XCTAssertThrowsError(_ = try JSONDecoder().decode(TestEvent.self, from: json.data(using: .utf8)!)) { error in + guard case DecodingError.dataCorrupted(let context) = error else { + XCTFail("Unexpected error: \(error)"); return + } + + XCTAssertEqual(context.codingPath.compactMap { $0.stringValue }, ["date"]) + XCTAssertEqual(context.debugDescription, "Expected date to be in iso8601 date format with fractional seconds, but `\(date)` does not forfill format") + XCTAssertNil(context.underlyingError) + } + } +} diff --git a/Tests/AWSLambdaEventsTests/S3Tests.swift b/Tests/AWSLambdaEventsTests/S3Tests.swift index 6f87c05d..19baa31e 100644 --- a/Tests/AWSLambdaEventsTests/S3Tests.swift +++ b/Tests/AWSLambdaEventsTests/S3Tests.swift @@ -69,7 +69,7 @@ class S3Tests: XCTestCase { XCTAssertEqual(record.eventVersion, "2.1") XCTAssertEqual(record.eventSource, "aws:s3") - XCTAssertEqual(record.awsRegion, "eu-central-1") + XCTAssertEqual(record.awsRegion, .eu_central_1) XCTAssertEqual(record.eventName, "ObjectCreated:Put") XCTAssertEqual(record.eventTime, Date(timeIntervalSince1970: 1_578_907_540.621)) XCTAssertEqual(record.userIdentity, S3.UserIdentity(principalId: "AWS:AAAAAAAJ2MQ4YFQZ7AULJ"))