brightdigit/spinetail
A lightweight async Swift client for the Mailchimp Marketing API,
What's a _Spinetail_?
A Spinetail is a type of Swift bird which shares its habitat with chimps (such as the chimp in Mailchimp).
π Features
- β Async/await API β no completion handlers, no Combine.
- β Datacenter-aware β the API host is derived automatically from your API
key's -<datacenter> suffix (e.g. ...-us21 β https://us21.api.mailchimp.com/3.0).
- β HTTP Basic auth built in β pass your API key, nothing else.
- β Testable by design β inject any
ClientTransport to replay fixtures instead of hitting the network.
- β Broad platform support β Linux, macOS, iOS, tvOS, watchOS, Windows,
Android, and WebAssembly (Wasm).
π Installation
Add Spinetail as a dependency in your Package.swift:
dependencies: [
.package(url: "https://github.com/brightdigit/Spinetail.git", from: "1.0.0-alpha.1")
]Then add it to your target:
.target(
name: "MyTarget",
dependencies: [
.product(name: "Spinetail", package: "Spinetail")
]
)Spinetail requires Swift 6.4 and supports macOS 13+, iOS 16+, tvOS 16+, and watchOS 9+ (plus Linux, Windows, Android, and Wasm).
πͺ Usage
Creating a client
Your Mailchimp API key ends in a -<datacenter> suffix; Spinetail uses it to derive the correct API host, so you only need to supply the key:
import Spinetail
let client = try MailchimpClient(
apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-us21"
)This convenience initializer uses the default URLSessionTransport. On platforms without URLSession (Wasm), or when you want to inject a custom transport, pass one explicitly:
let client = try MailchimpClient(
apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-us21",
transport: myTransport
)Listing sent campaigns
sentCampaigns(forListID:) returns the sent campaigns for a given Mailchimp list (audience), most recent first, mapped into the flat `Campaign` model:
let campaigns = try await client.sentCampaigns(forListID: "your-list-id")
for campaign in campaigns {
print(campaign.title ?? "Untitled", campaign.sendTime ?? .distantPast)
}Each Campaign exposes the fields the importer reads β all optional by design (the consumer validates presence):
| Property | Source | | -------------------- | ----------------------------- | | id | id | | longArchiveURL | long_archive_url | | sendTime | send_time | | subjectLine | settings.subject_line | | title | settings.title | | previewText | settings.preview_text | | segmentText | recipients.segment_text | | socialCardImageURL | social_card.image_url |
Fetching a campaign's archive HTML
archiveHTML(forCampaignID:) returns the rendered archive HTML for a campaign:
let html = try await client.archiveHTML(forCampaignID: "campaign-id")campaignContent(forCampaignID:) and plainText(forCampaignID:) are convenience methods over the same generated getCampaignsIdContent operation β campaignContent returns both archive_html and plain_text in one request, and plainText unwraps just the plain-text body.
let content = try await client.campaignContent(forCampaignID: "campaign-id")
let plain = try await client.plainText(forCampaignID: "campaign-id")Error handling
Invalid HTTP responses and missing content fields are mapped to MailchimpClient.ClientError. Errors thrown by the generated client or transport (for example network failures) propagate unchanged.
| Case | When | | ----------------------------- | ----------------------------------------------------------- | | .invalidAPIKey | The API key has no -<datacenter> suffix to derive a host. | | .invalidResponse | The server returned a non-200 / undocumented response. | | .missingHTML(campaignID:) | The campaign returned no archive_html. | | .missingPlainText(campaignID:) | The campaign returned no plain_text. |
do {
let html = try await client.archiveHTML(forCampaignID: id)
} catch MailchimpClient.ClientError.missingHTML(let campaignID) {
print("No archive HTML for \(campaignID)")
}π Architecture
Spinetail is two layered targets:
SpinetailOpenAPIβ generated, committed code (Types.swift,
Client.swift). Produced ahead of time (not by a build plugin) by Scripts/generate-openapi-spinetail.sh, which filters the full Mailchimp spec down to just getCampaigns and getCampaignsIdContent. Never edit this by hand β change the spec/config and regenerate.
Spinetailβ the hand-written public layer and the only target consumers
import: - MailchimpClient β the public client (host derivation + the two operations). - AuthenticationMiddleware β adds HTTP Basic auth (API key as the password; username ignored by Mailchimp). - Campaign β the flat DTO mapped from the generated campaign payload.
OpenAPIURLSession (the URLSession transport) does not build for WebAssembly, so anything depending on it is gated behind #if !os(WASI); Wasm callers supply an explicit transport.
π Supported Requests
Spinetail deliberately exposes only what its consumer needs:
| Operation | Mailchimp endpoint | MailchimpClient method | | ------------------------ | ----------------------------- | ------------------------------- | | getCampaigns | GET /campaigns | sentCampaigns(forListID:) | | getCampaignsIdContent | GET /campaigns/{id}/content | archiveHTML(forCampaignID:) |
To support more, see Adding an API operation.
π· Development
Tooling is pinned in .mise.toml and run through mise exec --. Install once with mise install.
- Build:
swift build - Test:
swift test(the suite uses
swift-testing, not XCTest)
- Lint + format + build check:
./Scripts/lint.sh - Regenerate the OpenAPI client:
./Scripts/generate-openapi-spinetail.sh
Adding an API operation
- Add the operation id to
filter: operations:in
Sources/SpinetailOpenAPI/openapi-generator-config.yaml.
- Regenerate with
./Scripts/generate-openapi-spinetail.sh(regeneration must
be diff-free unless the spec, config, or generator version changed).
- Surface the new operation through
MailchimpClient.
π Acknowledgments
Thanks to Apple's swift-openapi-generator team, and to Yonas Kolb whose SwagGen powered earlier versions of this package.
π License
This code is distributed under the MIT license. See the LICENSE file for more info.
Package Metadata
Repository: brightdigit/spinetail
Default branch: main
README: README.md