Skip to content

Commit

Permalink
Swift 6 update (#20)
Browse files Browse the repository at this point in the history
- Update package to Swift 6.0
- Switch from XCTest to Swift Testing
- Add more test coverage
- Update to latest AsyncHTTPClient APIs
- Add support for EU regional subusers
- Improve DocC
  • Loading branch information
fpseverino authored Sep 21, 2024
1 parent c7dda56 commit c00b291
Show file tree
Hide file tree
Showing 9 changed files with 124 additions and 67 deletions.
3 changes: 2 additions & 1 deletion .spi.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
version: 1
builder:
configs:
- documentation_targets: [SendGridKit]
- documentation_targets: [SendGridKit]
swift_version: 6.0
11 changes: 3 additions & 8 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// swift-tools-version:5.10
// swift-tools-version:6.0
import PackageDescription

let package = Package(
name: "sendgrid-kit",
platforms: [
.macOS(.v13),
.macOS(.v14),
],
products: [
.library(name: "SendGridKit", targets: ["SendGridKit"]),
Expand Down Expand Up @@ -32,9 +32,4 @@ let package = Package(

var swiftSettings: [SwiftSetting] { [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("ConciseMagicFile"),
.enableUpcomingFeature("ForwardTrailingClosures"),
.enableUpcomingFeature("DisableOutwardActorInference"),
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("StrictConcurrency=complete"),
] }
] }
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img src="https://img.shields.io/codecov/c/github/vapor-community/sendgrid-kit?style=plastic&logo=codecov&label=codecov">
</a>
<a href="https://swift.org">
<img src="https://design.vapor.codes/images/swift510up.svg" alt="Swift 5.10+">
<img src="https://design.vapor.codes/images/swift60up.svg" alt="Swift 6.0+">
</a>
</div>
<br>
Expand Down
12 changes: 8 additions & 4 deletions Sources/SendGridKit/Models/AdvancedSuppressionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ import Foundation

public struct AdvancedSuppressionManager: Codable, Sendable {
/// The unsubscribe group to associate with this email.
public var groupId: Int
///
/// See the Suppressions API to manage unsubscribe group IDs.
public var groupID: Int

/// An array containing the unsubscribe groups that you would like to be displayed on the unsubscribe preferences page.
///
/// This page is displayed in the recipient's browser when they click the unsubscribe link in your message.
public var groupsToDisplay: [String]?

public init(
groupId: Int,
groupID: Int,
groupsToDisplay: [String]? = nil
) {
self.groupId = groupId
self.groupID = groupID
self.groupsToDisplay = groupsToDisplay
}

private enum CodingKeys: String, CodingKey {
case groupId = "group_id"
case groupID = "group_id"
case groupsToDisplay = "groups_to_display"
}
}
27 changes: 18 additions & 9 deletions Sources/SendGridKit/Models/EmailAttachment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,49 @@ public struct EmailAttachment: Codable, Sendable {

/// The MIME type of the content you are attaching.
///
/// For example, `text/plain”` or `“text/html”`.
/// For example, `image/jpeg`, `text/html` or `application/pdf`.
public var type: String?

/// The filename of the attachment.
/// The attachment's filename, including the file extension.
public var filename: String

/// The content-disposition of the attachment specifying how you would like the attachment to be displayed.
public var disposition: String?
/// The attachment's content-disposition specifies how you would like the attachment to be displayed.
///
/// For example, inline results in the attached file being displayed automatically within the message
/// while attachment results in the attached file requiring some action to be taken before it is displayed
/// such as opening or downloading the file.
public var disposition: Disposition?

public enum Disposition: String, Codable, Sendable {
case inline
case attachment
}

/// The content ID for the attachment.
///
/// This is used when the disposition is set to “inline” and the attachment is an image,
/// allowing the file to be displayed within the body of your email.
public var contentId: String?
public var contentID: String?

public init(
content: String,
type: String? = nil,
filename: String,
disposition: String? = nil,
contentId: String? = nil
disposition: Disposition? = nil,
contentID: String? = nil
) {
self.content = content
self.type = type
self.filename = filename
self.disposition = disposition
self.contentId = contentId
self.contentID = contentID
}

private enum CodingKeys: String, CodingKey {
case content
case type
case filename
case disposition
case contentId = "content_id"
case contentID = "content_id"
}
}
29 changes: 18 additions & 11 deletions Sources/SendGridKit/Models/SendGridEmail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@ public struct SendGridEmail: Codable, Sendable {

public var replyTo: EmailAddress?

/// An array of recipients who will receive replies and/or bounces.
/// An array of recipients to whom replies will be sent.
///
/// Each object in this array must contain a recipient's email address.
/// Each object in the array may optionally contain a recipient's name.
/// You can use either the `reply_to property` or `reply_to_list` property but not both.
public var replyToList: [EmailAddress]?

/// The global, or “message level”, subject of your email.
/// The global or _message level_ subject of your email.
///
/// Subject lines set in personalizations objects will override this global subject line.
/// See line length limits specified in RFC 2822 for guidance on subject line character limits.
///
/// > Note: This may be overridden by `personalizations[x].subject`.
/// > Note: Min length: 1.
public var subject: String?

/// An array in which you may specify the content of your email.
Expand All @@ -29,7 +36,7 @@ public struct SendGridEmail: Codable, Sendable {
///
/// > Note: If you use a template that contains a subject and content (either text or HTML),
/// you do not need to specify those at the personalizations nor message level.
public var templateId: String?
public var templateID: String?

/// An object containing key/value pairs of header names and the value to substitute for them.
///
Expand Down Expand Up @@ -57,7 +64,7 @@ public struct SendGridEmail: Codable, Sendable {
///
/// Including a `batch_id` in your request allows you include this email in that batch,
/// and also enables you to cancel or pause the delivery of that batch.
public var batchId: String?
public var batchID: String?

/// An object allowing you to specify how to handle unsubscribes.
public var asm: AdvancedSuppressionManager?
Expand All @@ -79,12 +86,12 @@ public struct SendGridEmail: Codable, Sendable {
subject: String? = nil,
content: [EmailContent]? = nil,
attachments: [EmailAttachment]? = nil,
templateId: String? = nil,
templateID: String? = nil,
headers: [String: String]? = nil,
categories: [String]? = nil,
customArgs: [String: String]? = nil,
sendAt: Date? = nil,
batchId: String? = nil,
batchID: String? = nil,
asm: AdvancedSuppressionManager? = nil,
ipPoolName: String? = nil,
mailSettings: MailSettings? = nil,
Expand All @@ -97,12 +104,12 @@ public struct SendGridEmail: Codable, Sendable {
self.subject = subject
self.content = content
self.attachments = attachments
self.templateId = templateId
self.templateID = templateID
self.headers = headers
self.categories = categories
self.customArgs = customArgs
self.sendAt = sendAt
self.batchId = batchId
self.batchID = batchID
self.asm = asm
self.ipPoolName = ipPoolName
self.mailSettings = mailSettings
Expand All @@ -117,12 +124,12 @@ public struct SendGridEmail: Codable, Sendable {
case subject
case content
case attachments
case templateId = "template_id"
case templateID = "template_id"
case headers
case categories
case customArgs = "custom_args"
case sendAt = "send_at"
case batchId = "batch_id"
case batchID = "batch_id"
case asm
case ipPoolName = "ip_pool_name"
case mailSettings = "mail_settings"
Expand Down
8 changes: 8 additions & 0 deletions Sources/SendGridKit/Models/SendGridError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import Foundation

public struct SendGridError: Error, Decodable, Sendable {
public var errors: [SendGridErrorResponse]?

/// When applicable, this property value will be an error ID.
public var id: String?
}

public struct SendGridErrorResponse: Decodable, Sendable {
/// An error message.
public var message: String?

/// When applicable, this property value will be the field that generated the error.
public var field: String?

/// When applicable, this property value will be helper text or a link to documentation to help you troubleshoot the error.
public var help: String?
}
35 changes: 19 additions & 16 deletions Sources/SendGridKit/SendGridClient.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
@preconcurrency import Foundation
import Foundation
import NIO
import AsyncHTTPClient
import NIOHTTP1
import NIOFoundationCompat

public struct SendGridClient: Sendable {
let apiURL = "https://api.sendgrid.com/v3/mail/send"
let apiURL: String
let httpClient: HTTPClient
let apiKey: String

Expand All @@ -21,31 +21,34 @@ public struct SendGridClient: Sendable {
return decoder
}()

public init(httpClient: HTTPClient, apiKey: String) {
/// Initialize a new `SendGridClient`
///
/// - Parameters:
/// - httpClient: The `HTTPClient` to use for sending requests
/// - apiKey: The SendGrid API key
/// - forEU: Whether to use the API endpoint for global users and subusers or for EU regional subusers
public init(httpClient: HTTPClient, apiKey: String, forEU: Bool = false) {
self.httpClient = httpClient
self.apiKey = apiKey
self.apiURL = forEU ? "https://api.eu.sendgrid.com/v3/mail/send" : "https://api.sendgrid.com/v3/mail/send"
}

public func send(email: SendGridEmail) async throws {
var headers = HTTPHeaders()
headers.add(name: "Authorization", value: "Bearer \(apiKey)")
headers.add(name: "Content-Type", value: "application/json")

var request = HTTPClientRequest(url: apiURL)
request.method = .POST
request.headers = headers
request.body = try HTTPClientRequest.Body.bytes(encoder.encode(email))

let response = try await httpClient.execute(
request: .init(
url: apiURL,
method: .POST,
headers: headers,
body: .data(encoder.encode(email))
)
).get()
let response = try await httpClient.execute(request, timeout: .seconds(30))

// If the request was accepted, simply return
guard response.status != .ok && response.status != .accepted else { return }
if (200...299).contains(response.status.code) { return }

// JSONDecoder will handle empty body by throwing decoding error
let byteBuffer = response.body ?? ByteBuffer(.init())

throw try decoder.decode(SendGridError.self, from: byteBuffer)
// JSONDecoder will handle empty body by throwing decoding error
throw try await decoder.decode(SendGridError.self, from: response.body.collect(upTo: 1024 * 1024))
}
}
64 changes: 47 additions & 17 deletions Tests/SendGridKitTests/SendGridTestsKit.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
import XCTest
import Testing
import AsyncHTTPClient
@testable import SendGridKit

class SendGridKitTests: XCTestCase {
private var httpClient: HTTPClient!
private var client: SendGridClient!
struct SendGridKitTests {
var client: SendGridClient

override func setUp() {
httpClient = HTTPClient(eventLoopGroupProvider: .singleton)
// TODO: Replace with your API key to test!
client = SendGridClient(httpClient: httpClient, apiKey: "YOUR-API-KEY")
}

override func tearDown() async throws {
try await httpClient.shutdown()
init() {
// TODO: Replace with a valid API key to test
client = SendGridClient(httpClient: HTTPClient.shared, apiKey: "YOUR-API-KEY")
}

func testSendEmail() async throws {
@Test func sendEmail() async throws {
// TODO: Replace to address with the email address you'd like to recieve your test email
let emailAddress = EmailAddress("TO-ADDRESS")
// TODO: Replace from address with the email address associated with your verified Sender Identity
Expand All @@ -28,20 +22,56 @@ class SendGridKitTests: XCTestCase {
content: "Hello, World!".data(using: .utf8)!.base64EncodedString(),
type: "text/plain",
filename: "hello.txt",
disposition: "attachment"
disposition: .attachment
)

let emailContent = EmailContent("This email was sent using SendGridKit!")

let setting = Setting(enable: true)
let mailSettings = MailSettings(
bypassListManagement: setting,
bypassSpamManagement: setting,
bypassBounceManagement: setting,
footer: Footer(enable: true, text: "footer", html: "<strong>footer</strong>"),
sandboxMode: setting
)

let trackingSettings = TrackingSettings(
clickTracking: ClickTracking(enable: true, enableText: true),
openTracking: OpenTracking(enable: true, substitutionTag: "open_tracking"),
subscriptionTracking: SubscriptionTracking(
enable: true,
text: "sub_text",
html: "<strong>sub_html</strong>",
substitutionTag: "sub_tag"
),
ganalytics: GoogleAnalytics(
enable: true,
utmSource: "utm_source",
utmMedium: "utm_medium",
utmTerm: "utm_term",
utmContent: "utm_content",
utmCampaign: "utm_campaign"
)
)

let asm = AdvancedSuppressionManager(groupID: 21, groupsToDisplay: ["group1", "group2"])

let email = SendGridEmail(
personalizations: [personalization],
from: fromEmailAddress,
content: [emailContent],
attachments: [attachment]
attachments: [attachment],
asm: asm,
mailSettings: mailSettings,
trackingSettings: trackingSettings
)

do {
try await withKnownIssue {
try await client.send(email: email)
} catch {}
} when: {
// TODO: Replace with `false` when you have a valid API key
true
}
}
}

0 comments on commit c00b291

Please sign in to comment.