Skip to content

Networking — a port you plug into, not a client Murti builds

Murti does not build, ship, or force a network client. MurtiCore imports no networking library. It defines one port (a protocol), and you plug in an adapter that wraps whatever you already use — Alamofire, Moya, or plain URLSession. Swap libraries by swapping the adapter; the engine never changes.

This is the Strategy / Ports-and-Adapters pattern (dependency inversion): the engine depends on the abstraction, each adapter depends on the abstraction and a library, and your app picks which adapter to inject.

The port (all MurtiCore defines)

public protocol MurtiNetworkClient: Sendable {
    func perform(_ request: NamedRequest, params: [String: MurtiValue]) async throws -> Data
}

The whole contract: given an allow-listed named request + params, return Data. Library types (AFError, Moya's Response) never cross this boundary.

The picture

flowchart TB
    App["Your app<br/>picks one adapter and passes it in"]

    subgraph core["MurtiCore — imports no networking library"]
        CMD["APICommand"]
        PORT["«port» MurtiNetworkClient<br/>perform(NamedRequest, params) → Data"]
        CMD --> PORT
    end

    subgraph plug["Pluggable adapters — pick one"]
        AF["AlamofireClient"]
        MOYA["MoyaClient"]
        URLS["URLSessionClient"]
    end

    AF -. conforms .-> PORT
    MOYA -. conforms .-> PORT
    URLS -. conforms .-> PORT

    AF --> L1[("Alamofire")]
    MOYA --> L2[("Moya")]
    URLS --> L3[("URLSession · Foundation")]

    App -->|injects chosen adapter| CMD

    classDef port fill:#e7f0ff,stroke:#2b6cb0,color:#111;
    class PORT port;

Core points only at the port. Adapters point at the port (conform) and at a library (wrap). Your app is the only place that names a concrete library.

Adapters (pick one — ~15 lines each)

Each maps NamedRequest → your real endpoint (the app owns that mapping and its auth/pins; Murti can't know them).

URLSession — zero third-party dependencies (pinning via delegate)

import Foundation
import MurtiCore

struct URLSessionClient: MurtiNetworkClient {
    let baseURL: URL
    let session: URLSession   // a session whose delegate does cert pinning

    func perform(_ request: NamedRequest, params: [String: MurtiValue]) async throws -> Data {
        let urlRequest = Endpoints.build(request, params: params, baseURL: baseURL)
        let (data, response) = try await session.data(for: urlRequest)
        guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
            throw MurtiError.network(status: (response as? HTTPURLResponse)?.statusCode)
        }
        return data
    }
}

Alamofire — pinning via ServerTrustManager

import Alamofire
import MurtiCore

struct AlamofireClient: MurtiNetworkClient {
    let session: Session   // Session(serverTrustManager: ServerTrustManager(evaluators: …))

    func perform(_ request: NamedRequest, params: [String: MurtiValue]) async throws -> Data {
        let endpoint = Endpoints.build(request, params: params)   // → URL + HTTPMethod
        return try await session.request(endpoint.url, method: endpoint.method)
            .validate()
            .serializingData()
            .value
    }
}

Moya — via TargetType

import Moya
import MurtiCore

struct MoyaClient: MurtiNetworkClient {
    let provider: MoyaProvider<AppTarget>

    func perform(_ request: NamedRequest, params: [String: MurtiValue]) async throws -> Data {
        let target = AppTarget(request: request, params: params)   // map to your TargetType
        return try await withCheckedThrowingContinuation { continuation in
            provider.request(target) { result in
                switch result {
                case .success(let response): continuation.resume(returning: response.data)
                case .failure: continuation.resume(throwing: MurtiError.network(status: nil))
                }
            }
        }
    }
}

Wiring (at app startup)

let client: any MurtiNetworkClient = AlamofireClient(session: pinnedSession)

let engine = MurtiEngine(
    componentFactory: .withBuiltins,
    screenFactory: screens,
    actionDispatcher: MurtiActionDispatcher(network: client, navigator: navigator)
)

Swap AlamofireClient for MoyaClient or URLSessionClient — nothing else in the app changes. That is the plug.

Same pattern everywhere

Components (Lottie, charts) plug into MurtiComponent, signature verification into MurtiSignatureVerifier, caching into its own port. Murti defines the protocol; you plug in the implementation. Nothing library-specific ever lives in MurtiCore.