Skip to main content
Install the ZEN Engine and evaluate your first decision in iOS.

Installation

dependencies: [
    .package(url: "https://github.com/gorules/zen-engine-swift", from: "0.4.7")
]
Or in Xcode: File → Add Package Dependencies → Enter the repository URL.

Basic usage

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 the ZenDecisionLoaderCallback protocol with a func load(key: String) async throws -> Data? method. Use a thread-safe cache for optimal performance.

Bundle

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

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

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

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

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

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:
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

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:
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:
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 single ZenEngine instance at application startup and reuse it for all evaluations.
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
    }
}
Cache decisions persistently. Use the Caches or Documents directory to cache downloaded decisions for offline use. Evaluate off the main thread. Use 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.