Monday, March 4, 2024

Swift Rule Engine using Javascript for your iOS/MacOS application


This kind of workarounds are required when we want dynamic validation or to run some algorithm dynamically at runtime. We can download the business logic [as javascript text or file] from the server or can add to the bundle and can load and execute when it's needed.

In this way, we can keep the core business logic must be the same over multiple platform and can control without updating the application.

Form your app, you can pass values as parameters and the script can evaluate it and can return the result as plain text or JSON text if needed. 

First load the script using scripRunner.loadWebViewWithScript(script), and we can invoke a function using scripRunner.evaluateFunction(fnName: "function")


See the example code of passing a contact object to a script function and that function validate the contact object and returning a json object with true or false value.



struct ContentView: View {
let scripRunner = JavascriptHandler()
var body: some View {
VStack {
Button {
Task {
let c1 = Contact(name: "JP", age: 14, knowSwimming: false)
let result = try await scripRunner.evaluateFunction(fnName: "validate('\(c1.toJSONString)')")
let obj = try JSONDecoder().decode(Result.self, from: Data(result.utf8))
print("obj.result = \(obj.result)")
}
} label: {
Text("Click me")
}
}
.onAppear {
Task {
try await scripRunner.loadWebViewWithScript(script)
}
}
.padding()
}
}
#Preview {
ContentView()
}
struct Contact: Encodable {
let name: String
let age: Int
let knowSwimming: Bool
}
struct Result: Decodable {
var result: Bool
}
let script = """
<script type="text/javascript">
function validate(contact) {
const contactObj = JSON.parse(contact);
if (contactObj.age > 12 && contactObj.knowSwimming == true){
return '{\"result\" : true}';
}
return '{\"result\" : false}';
}
</script>
"""
The following  JavaScript handler file will help us to do the tasks easier.


import WebKit
public class JavascriptHandler: NSObject {
enum JSError: Error {
case evaluation
}
private var jsWebView: WKWebView?
private var libraryPath: URL?
private var continuation: CheckedContinuation<Bool, Never>?
public init(_ libraryPath: URL? = nil) {
self.libraryPath = libraryPath
}
private func makeWebView() -> WKWebView {
let webView = WKWebView(frame: CGRect.zero, configuration: webViewConfiguration)
webView.navigationDelegate = self
webView.allowsLinkPreview = true
webView.allowsBackForwardNavigationGestures = true
webView.uiDelegate = self
return webView
}
private var webViewConfiguration: WKWebViewConfiguration {
let config = WKWebViewConfiguration()
let source = "delete window.SharedWorker;"
let script = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentStart, forMainFrameOnly: false)
config.userContentController.addUserScript(script)
return config
}
@discardableResult
@MainActor
public func loadWebViewWithScript(_ script: String) async throws -> Bool {
jsWebView = makeWebView()
if !script.isEmpty {
jsWebView?.loadHTMLString(script, baseURL: libraryPath)
let result = await withCheckedContinuation { sendable in
continuation = sendable
}
return result
}
return false
}
@MainActor
public func evaluateFunction(fnName: String) async throws -> String {
guard let result = try await jsWebView?.evaluateJavaScript(fnName) else {
throw JSError.evaluation
}
return String(format: "%@", result as! CVarArg)
}
}
extension JavascriptHandler: WKNavigationDelegate, WKUIDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
continuation?.resume(returning: true)
}
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
continuation?.resume(returning: false)
}
}
extension Encodable {
var toJSONString: String {
guard let data = try? JSONEncoder().encode(self) else { return "" }
return String(decoding: data, as: UTF8.self)
}
}
As we are using async/await instead of completion handlers, we can evaluate a js function immediately after loading the html content. 
In this example, the loading of script content is done with onAppear and evaluating on the button click. In this way, we can do the load once and evaluate any times.