A Swift SDK for building iOS, macOS, tvOS, and watchOS apps on top of Drupal.
It wraps the most common operations of Drupal's
RESTful Web Services API
(Drupal 10 / 11) behind a small, modern Swift surface built on async /
await and closure-based dependency injection — no singleton, no protocols.
History: 5.x is a ground-up rewrite of the former waterwheel-swift SDK. The Swift module has been renamed
DrupalIOSSDK, the main value type is nowDrupalClient, and UI has moved to SwiftUI in a separate productDrupalIOSSDKUI. See Migrating from 4.x.
Features •
Requirements •
Installation •
The DrupalClient DI pattern •
Usage •
SwiftUI helpers •
Migrating from 4.x
- Drupal 10 / 11 core REST module
-
async/awaitAPI built onURLSession - Closure-based dependency injection (no singleton, no protocols) — inspired by Kyle Browning's Dependency Injection in SwiftUI Without the Ceremony
- SwiftUI environment integration:
@Environment(\.drupal) var drupal - No third-party dependencies — zero Alamofire, zero SwiftyJSON, zero ObjectMapper
- Cookie session auth with automatic CSRF token management
- HTTP Basic auth and OAuth 2 Bearer token auth
- Entity CRUD (node, comment, user, taxonomy term, media, file — plus anything custom)
- Views REST export helper
- Separate SwiftUI helpers product:
DrupalAuthButton,DrupalLoginView,DrupalViewList - Swift Package Manager and CocoaPods support
| Product | Module | Platforms | Purpose |
|---|---|---|---|
DrupalIOSSDK |
DrupalIOSSDK |
iOS, macOS, tvOS, watchOS | Closure-based DrupalClient + auth |
DrupalIOSSDKUI |
DrupalIOSSDKUI |
iOS, macOS, tvOS, watchOS | SwiftUI helpers (depends on core) |
- iOS 15.0+ / macOS 12.0+ / tvOS 15.0+ / watchOS 8.0+
- Swift 5.9 / Xcode 15+
- Drupal 10 or 11 with the core
restmodule enabled and a user role permitted to use theapplication/jsonformat
Add to Package.swift:
dependencies: [
.package(url: "https://github.com/kylebrowning/drupal-ios-sdk.git", from: "5.0.0")
]Then opt into the products you need:
.product(name: "DrupalIOSSDK", package: "drupal-ios-sdk"), // networking / DI
.product(name: "DrupalIOSSDKUI", package: "drupal-ios-sdk"), // optional SwiftUI helpersOr from Xcode: File → Add Package Dependencies… and paste the repo URL.
pod 'DrupalIOSSDK' # core (default subspec)
pod 'DrupalIOSSDK/UI' # optional SwiftUI helpersDrupalIOSSDK exposes its entire API surface as a single value-type struct
whose stored properties are @Sendable async closures. The struct itself is
the protocol — different factory functions produce different instances:
public struct DrupalClient: Sendable {
public var login: @Sendable (String, String) async throws -> LoginResponse
public var logout: @Sendable () async throws -> Void
public var get: @Sendable (EntityType, String, [String: String]) async throws -> Data
public var create: @Sendable (EntityType, Data) async throws -> Data
public var update: @Sendable (EntityType, String, Data) async throws -> Data
public var delete: @Sendable (EntityType, String) async throws -> Void
public var view: @Sendable (String, [String: String]) async throws -> Data
public var request: @Sendable (String, String, [String: String], Data?) async throws -> Data
// …plus isLoggedIn, csrfToken, setAuthentication, refreshCSRFToken
}The SDK ships three factories:
| Factory | What it does |
|---|---|
.live(baseURL:) |
Real URLSession-backed client. Use this in production. |
.mock(initiallyLoggedIn:) |
In-memory fake for SwiftUI previews and tests. |
.unimplemented |
Every closure throws DrupalError.unimplemented — the Environment default so missing DI fails loudly. |
Injection uses SwiftUI's environment system:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.drupal, .live(baseURL: URL(string: "https://example.com")!))
}
}
}
struct ContentView: View {
@Environment(\.drupal) private var drupal
var body: some View { /* use drupal.login, drupal.get, … */ }
}In tests or Xcode Previews, swap in .mock() or a custom DrupalClient
built from your own closures:
#Preview {
ContentView()
.environment(\.drupal, .mock(initiallyLoggedIn: true))
}
let fake = DrupalClient(
login: { _, _ in LoginResponse(csrfToken: "t", logoutToken: "u", currentUser: nil) },
// …other closures…
)All snippets assume @Environment(\.drupal) private var drupal (or a client
passed in via initializer).
// Cookie session
let session = try await drupal.login("admin", "hunter2")
// HTTP Basic
drupal.setAuthentication(.basic(username: "admin", password: "hunter2"))
// OAuth 2 bearer
drupal.setAuthentication(.bearer(token: accessToken))
// Logout
try await drupal.logout()CSRF tokens are fetched automatically from /session/token the first time an
unsafe method is issued on a cookie-authenticated session. Force a refresh with
try await drupal.refreshCSRFToken().
let data: Data = try await drupal.get(.node, "36", [:]) // raw
struct MyNode: Decodable { /* … */ }
let node: MyNode = try await drupal.get(MyNode.self, entity: .node, id: "36")struct NewArticle: Encodable {
let type: [[String: String]]
let title: [[String: String]]
let body: [[String: String]]
}
let payload = NewArticle(
type: [["target_id": "article"]],
title: [["value": "Hello World"]],
body: [["value": "How are you?"]]
)
try await drupal.create(.node, body: payload)
try await drupal.update(.node, id: "36", body: payload)
try await drupal.delete(.node, "36")let data = try await drupal.view("api/latest-articles", [:])let data = try await drupal.request("some/custom/path", "GET", ["_format": "json"], nil)
let decoded: MyType = try await drupal.get(MyType.self, path: "some/custom/path")DrupalIOSSDKUI ships three SwiftUI views, each backed by
@Environment(\.drupal):
A button that reads drupal.isLoggedIn() on appear and observes
.drupalDidLogin / .drupalDidLogout notifications to stay in sync. Triggers
logout automatically when tapped while authenticated.
DrupalAuthButton(
onLoginTap: { isPresentingLogin = true },
onLogout: { result in print("logged out:", result) }
)A ready-to-use username/password form that calls drupal.login and surfaces
errors inline.
DrupalLoginView(
prefilledUsername: "demo",
onSuccess: { _ in dismiss() },
onCancel: { dismiss() }
)Fetches a Drupal View's REST export and renders rows with a SwiftUI builder.
struct Article: Decodable, Identifiable {
let id: String
let title: String?
}
DrupalViewList<Article, AnyView>(viewPath: "frontpage") { article in
AnyView(Text(article.title ?? "Untitled"))
}- Open
DrupalIOSSDKDemo/DrupalIOSSDKDemo-iOS/DrupalIOSSDKDemo-iOS.xcworkspacein Xcode 15+. The project declares a local Swift Package reference to the repo root, so no Carthage or CocoaPods step is required. - Edit
DrupalIOSSDKDemo-iOS/DrupalIOSSDKDemoApp.swiftand point.live(baseURL:)at your Drupal site. - Build and run on an iOS 15+ device or simulator.
- Module & types renamed.
import waterwheel→import DrupalIOSSDK. Instead of aWaterwheel.sharedsingleton, you now construct (or inject) aDrupalClient. - Inject; don't reach for shared state.
waterwheel.setDrupalURL("...")→.environment(\.drupal, .live(baseURL: URL(string: "...")!))at your SwiftUI app root. waterwheel.login(username:password:) { ... }→try await drupal.login(username, password)waterwheel.nodeGet(nodeId:)→try await drupal.get(.node, id, [:])waterwheel.entityPost(entityType:params:)→try await drupal.create(.node, body: payload)waterwheel.entityPatch(entityType:entityId:params:)→try await drupal.update(.node, id: id, body: payload)waterwheel.entityDelete(...)→try await drupal.delete(.node, id)- UI moved to SwiftUI.
waterwheelAuthButton,waterwheelLoginViewController, andwaterwheelViewTableViewController(UIKit) were replaced byDrupalAuthButton,DrupalLoginView, andDrupalViewList(SwiftUI). - Notification names moved to
.drupalDidLogin,.drupalDidLogout,.drupalDidStartRequest,.drupalDidFinishRequest. DataResponse<Any>/SwiftyJSON.JSONreturn types are gone — useCodable,JSONDecoder, or rawData.
| SDK version | Drupal version | Notes |
|---|---|---|
| 5.x | Drupal 10, 11 | Swift 5.9, async/await, SwiftUI, closure-based DI, zero deps |
| 4.x | Drupal 8 | Swift 3, Alamofire 4, SwiftyJSON, module waterwheel |
| 3.x | Drupal 8 | Objective-C |
| 2.x | Drupal 6–7 | Obj-C, requires the services module |
- Found a bug? Open an issue.
- Want a feature? Open an issue or a pull request.