Installation
Copy
dependencies: [
.package(url: "https://github.com/gorules/zen-engine-swift", from: "0.4.7")
]
Basic usage
Copy
import UIKit
import ZenUniffi
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
Task {
guard let ruleData = Bundle.main.url(forResource: "pricing", withExtension: "json")
.flatMap({ try? Data(contentsOf: $0) }) else {
return
}
let engine = ZenEngine(loader: nil, customNode: nil)
let decision = try engine.createDecision(content: ruleData)
let input = """
{
"customer": { "tier": "gold", "yearsActive": 3 },
"order": { "subtotal": 150, "items": 5 }
}
""".data(using: .utf8)!
let response = try await decision.evaluate(context: input, options: nil)
if let resultString = String(data: response.result, encoding: .utf8) {
print(resultString)
// => {"discount":0.15,"freeShipping":true}
}
}
}
}
Loader
The loader pattern enables dynamic decision loading from any storage backend. Implement theZenDecisionLoaderCallback protocol with a func load(key: String) async throws -> Data? method. Use a thread-safe cache for optimal performance.
Bundle
Copy
import ZenUniffi
import Foundation
class BundleLoader: ZenDecisionLoaderCallback {
private var cache: [String: Data] = [:]
private let lock = NSLock()
func load(key: String) async throws -> Data? {
lock.lock()
if let cached = cache[key] {
lock.unlock()
return cached
}
lock.unlock()
guard let url = Bundle.main.url(forResource: key, withExtension: nil),
let data = try? Data(contentsOf: url) else {
return nil
}
lock.lock()
cache[key] = data
lock.unlock()
return data
}
}
func createEngine() -> ZenEngine {
let loader = BundleLoader()
return ZenEngine(loader: loader, customNode: nil)
}
Documents directory
Copy
import ZenUniffi
import Foundation
class DocumentsLoader: ZenDecisionLoaderCallback {
private var cache: [String: Data] = [:]
private let lock = NSLock()
private let documentsURL: URL
init() {
documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
func load(key: String) async throws -> Data? {
lock.lock()
if let cached = cache[key] {
lock.unlock()
return cached
}
lock.unlock()
let fileURL = documentsURL.appendingPathComponent("rules/\(key)")
let data = try Data(contentsOf: fileURL)
lock.lock()
cache[key] = data
lock.unlock()
return data
}
}
func createEngine() -> ZenEngine {
let loader = DocumentsLoader()
return ZenEngine(loader: loader, customNode: nil)
}
Firebase Remote Config
Copy
import ZenUniffi
import Foundation
import FirebaseRemoteConfig
class RemoteConfigLoader: ZenDecisionLoaderCallback {
private let remoteConfig = RemoteConfig.remoteConfig()
private var cache: [String: Data] = [:]
private let lock = NSLock()
func load(key: String) async throws -> Data? {
lock.lock()
if let cached = cache[key] {
lock.unlock()
return cached
}
lock.unlock()
let configKey = "decision_\(key.replacingOccurrences(of: ".", with: "_"))"
let value = remoteConfig.configValue(forKey: configKey).dataValue
lock.lock()
cache[key] = value
lock.unlock()
return value
}
}
func createEngine() -> ZenEngine {
let loader = RemoteConfigLoader()
return ZenEngine(loader: loader, customNode: nil)
}
Remote URL with caching
Copy
import ZenUniffi
import Foundation
class RemoteLoader: ZenDecisionLoaderCallback {
private var cache: [String: Data] = [:]
private let lock = NSLock()
private let cacheURL: URL
init() {
cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
.appendingPathComponent("rules")
}
func load(key: String) async throws -> Data? {
lock.lock()
if let cached = cache[key] {
lock.unlock()
return cached
}
lock.unlock()
let fileURL = cacheURL.appendingPathComponent(key)
let data: Data
if FileManager.default.fileExists(atPath: fileURL.path) {
data = try Data(contentsOf: fileURL)
} else {
let url = URL(string: "https://api.example.com/rules/\(key)")!
let (responseData, _) = try await URLSession.shared.data(from: url)
try FileManager.default.createDirectory(at: cacheURL, withIntermediateDirectories: true)
try responseData.write(to: fileURL)
data = responseData
}
lock.lock()
cache[key] = data
lock.unlock()
return data
}
}
func createEngine() -> ZenEngine {
let loader = RemoteLoader()
return ZenEngine(loader: loader, customNode: nil)
}
Remote URL with zip
Copy
import ZenUniffi
import Foundation
import ZIPFoundation
var rules: [String: Data] = [:]
func initializeRules() async throws {
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
let cacheFile = cacheURL.appendingPathComponent("decisions.zip")
let zipData: Data
if FileManager.default.fileExists(atPath: cacheFile.path) {
zipData = try Data(contentsOf: cacheFile)
} else {
let url = URL(string: "https://api.example.com/decisions.zip")!
let (responseData, _) = try await URLSession.shared.data(from: url)
try responseData.write(to: cacheFile)
zipData = responseData
}
// Extract all decisions from zip
guard let archive = Archive(data: zipData, accessMode: .read) else { return }
for entry in archive {
var entryData = Data()
_ = try archive.extract(entry) { chunk in
entryData.append(chunk)
}
rules[entry.path] = entryData
}
}
class ZipLoader: ZenDecisionLoaderCallback {
func load(key: String) async throws -> Data? {
return rules[key]
}
}
func createEngine() -> ZenEngine {
let loader = ZipLoader()
return ZenEngine(loader: loader, customNode: nil)
}
Firebase Storage with zip
Copy
import ZenUniffi
import Foundation
import FirebaseStorage
import ZIPFoundation
var rules: [String: Data] = [:]
func initializeRules() async throws {
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
let cacheFile = cacheURL.appendingPathComponent("decisions.zip")
let zipData: Data
if FileManager.default.fileExists(atPath: cacheFile.path) {
zipData = try Data(contentsOf: cacheFile)
} else {
// Download from Firebase Storage
let storage = Storage.storage()
let ref = storage.reference().child("decisions.zip")
let maxSize: Int64 = 10 * 1024 * 1024 // 10MB max
zipData = try await ref.data(maxSize: maxSize)
try zipData.write(to: cacheFile)
}
// Extract all decisions from zip
guard let archive = Archive(data: zipData, accessMode: .read) else { return }
for entry in archive {
var entryData = Data()
_ = try archive.extract(entry) { chunk in
entryData.append(chunk)
}
rules[entry.path] = entryData
}
}
class FirebaseStorageLoader: ZenDecisionLoaderCallback {
func load(key: String) async throws -> Data? {
return rules[key]
}
}
func createEngine() -> ZenEngine {
let loader = FirebaseStorageLoader()
return ZenEngine(loader: loader, customNode: nil)
}
Async/Await
Evaluation functions are async, integrating natively with Swift concurrency:Copy
import ZenUniffi
func evaluateMultiple(decision: ZenDecision, inputs: [Data]) async throws -> [ZenEvaluateResponse] {
try await withThrowingTaskGroup(of: ZenEvaluateResponse.self) { group in
for input in inputs {
group.addTask {
try await decision.evaluate(context: input, options: nil)
}
}
var results: [ZenEvaluateResponse] = []
for try await result in group {
results.append(result)
}
return results
}
}
Error handling
Copy
import ZenUniffi
do {
let response = try await decision.evaluate(context: input, options: nil)
if let resultString = String(data: response.result, encoding: .utf8) {
print(resultString)
}
} catch let error as ZenError {
print("Evaluation failed: \(error.localizedDescription)")
} catch {
print("Unexpected error: \(error)")
}
Tracing
Enable tracing to inspect decision execution:Copy
import ZenUniffi
let options = ZenEvaluateOptions(trace: true, maxDepth: nil)
let response = try await decision.evaluate(context: input, options: options)
if let traceData = response.trace,
let traceString = String(data: traceData, encoding: .utf8) {
print("Trace: \(traceString)")
}
print("Performance: \(response.performance)")
Expression utilities
Evaluate ZEN expressions outside of a decision context:Copy
import ZenUniffi
// Standard expressions
let result = try evaluateExpression(
expression: "a + b",
context: #"{ "a": 5, "b": 3 }"#.data(using: .utf8)!
)
// => 8
let total = try evaluateExpression(
expression: "sum(items)",
context: #"{ "items": [1, 2, 3, 4] }"#.data(using: .utf8)!
)
// => 10
// Unary expressions (comparison against $)
let isValid = try evaluateUnaryExpression(
expression: ">= 5",
context: #"{ "$": 10 }"#.data(using: .utf8)!
)
// => true
let inList = try evaluateUnaryExpression(
expression: "'US', 'CA', 'MX'",
context: #"{ "$": "US" }"#.data(using: .utf8)!
)
// => true
Best practices
Initialize the engine once. Create a singleZenEngine instance at application startup and reuse it for all evaluations.
Copy
class RulesEngine {
static let shared = RulesEngine()
private let engine: ZenEngine
private init() {
engine = ZenEngine(loader: nil, customNode: nil)
}
func evaluate(decision: Data, context: Data) async throws -> Data {
let dec = try engine.createDecision(content: decision)
let response = try await dec.evaluate(context: context, options: nil)
return response.result
}
}
Task or Task.detached to avoid blocking the main thread during evaluation.
Bundle fallback decisions. Include decisions in your app bundle as fallback for first launch or network failures.