Cookbook
Your network stack
Your app already has one: a token it refreshes, interceptors, tracing, certificate pinning. Hand it to the core's network ports and every request the core makes goes through it, with nothing Undra-specific to configure.
The problem#
The core never makes a request; the platform does, through the Http port (and WebSocket and Sse where the core asks for them). The default adapters use the platform's own client. That is not your client: on Android HttpURLConnection is OkHttp underneath, but the platform's instance, so a request from the core carries no token, shows in no trace and is not pinned. The fix is not an adapter of your own (the contract is hundreds of lines: typed errors, a timeout that covers the body, cancellation that closes the socket, streams that stop the server when the core stops reading). It is passing your client to the adapter that already is that contract.
The recipe#
- Android: add the optional
okhttp-adaptersmodule (com.github.shreypdev.undra:okhttp-adapters, at the runtime's version) and callAndroidPlatformDefaults.installWithOkHttp(core, context, client)in place ofinstall.Http,WebSocketandSsego through yourOkHttpClient: its interceptors, itsAuthenticator(a401is answered with a refreshed token and the request goes again), its event listeners, itsCertificatePinner. One exception is OkHttp's own: a WebSocket's upgrade is seen by your application interceptors, not by your network interceptors or your event listener. - iOS: pass your
URLSessiontoHttpAdapter,URLSessionWebSocketAdapterandURLSessionSseAdapter. Its configuration, its delegate (server trust and the pinning built on it, authentication challenges) and itsURLProtocolclasses apply. Give event streams a session with the same delegate and a configuration that lets a stream stay quiet. - Web: pass your
fetchandWebSockettofetchHttp,fetchSseandbrowserWebSocket. Whatever your wrapper does to a request, it does to the core's. - React Native: nothing to pass.
reactNativeHttp()is React Native'sfetch, which is the app's networking module, so what the app configures there applies.
Show the Swift, Kotlin and TypeScript
/// The app's session delegate: pinning and authentication challenges are answered here, for every request the core makes.
final class AppSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge
) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
// Your pinning goes here: compare challenge.protectionSpace.serverTrust with the certificate you ship.
return (.performDefaultHandling, nil)
}
}
/// The ports that carry the core's traffic, over the app's own session. Event streams get a session of their own, with the
/// same delegate and a configuration that lets a stream stay quiet.
func appAdapters(configuration: URLSessionConfiguration, delegate: AppSessionDelegate) -> Adapters {
configuration.httpAdditionalHeaders = ["X-App-Version": "1.4.2"] // on everything the core sends
let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
let streaming = (configuration.copy() as? URLSessionConfiguration) ?? .default
streaming.timeoutIntervalForRequest = 24 * 60 * 60
streaming.httpMaximumConnectionsPerHost = 1_024
let streams = URLSession(configuration: streaming, delegate: delegate, delegateQueue: nil)
return Adapters.platformDefault
.replacing(HttpAdapter(session: session))
.replacing(URLSessionWebSocketAdapter(session: session))
.replacing(URLSessionSseAdapter(session: streams))
}/** The app's one client: a token on every request, a refresh when the server says 401, and pinning. */
fun appClient(tokens: TokenStore): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor { chain ->
val signed = chain.request().newBuilder().header("Authorization", "Bearer ${tokens.access}").build()
chain.proceed(signed)
}
.authenticator { _, response ->
// OkHttp asks on a 401. A request that is already a retry gives up: the refresh did not help.
if (response.priorResponse != null) return@authenticator null
val fresh = tokens.refresh() ?: return@authenticator null
response.request.newBuilder().header("Authorization", "Bearer $fresh").build()
}
.certificatePinner(CertificatePinner.Builder().add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").build())
.build()
/** In `Application.onCreate`, in place of `AndroidPlatformDefaults.install(core, this)`: Http, WebSocket and Sse go through [client]. */
fun installNetwork(core: UndraCore, context: Context, client: OkHttpClient) {
AndroidPlatformDefaults.installWithOkHttp(core, context, client)
}// The app's fetch: a token on every request, a refresh when the server says 401. The core's requests go through it too.
const appFetch: typeof fetch = async (input, init) => {
const send = () => {
const headers = new Headers(init?.headers);
headers.set("authorization", `Bearer ${session.accessToken}`);
return fetch(input, { ...init, headers });
};
const first = await send();
if (first.status !== 401) return first;
await session.refresh();
return send();
};
export const adapters = { ...browserAdapters(), http: fetchHttp({ fetch: appFetch }) };
export const ports = {
[OptInPortIds.WebSocket.portId]: webSocketPort(browserWebSocket({ WebSocket: AppWebSocket })),
[OptInPortIds.Sse.portId]: ssePort(fetchSse({ fetch: appFetch })),
};What goes through your stack, and what is yours#
| Port | Kotlin on OkHttp | Swift on URLSession |
|---|---|---|
Http | the whole call, as the app's own requests go | the whole task |
WebSocket | the upgrade request (OkHttp gives its listener no WebSocket events) | the upgrade: the connection is a task of your session |
Sse | the request, on a client derived from yours with no read timeout and HTTP/1.1 | the request, on the session you give the Sse adapter |
What your client decides stays yours: redirects, retries, the timeouts when a request has none of its own, compression, caching, how many calls run at once (the core's requests wait their turn with yours). The core's contract holds on top: an error status is a response, a failure is a typed error, the request's own timeout bounds the whole exchange, cancelling the call closes the socket.
What the tests prove#
- Kotlin: the Http, WebSocket and Sse contract suites that run on the default adapters run on OkHttp's too, on the JVM and on a device. An interceptor sees every request and changes a header, an
Authenticatorrefreshes a token and the request, its body included, goes again, an event listener sees each call, and the recipe's client above is run against a local server. - Swift: the WebSocket suite runs a second time on an app's session, and Http, Sse and WebSocket are shown to carry the session's configuration and to reach its delegate.
- TypeScript: a wrapped
fetchsees every request, adds a header and answers a401with a refreshed token; an app'sWebSocketconstructor adds a token to the upgrade.
Limits#
- OkHttp's WebSocket repairs malformed UTF-8 in a text frame (U+FFFD) where the default adapter fails the connection with close code 1007; keep
ClientWebSocketAdapterfor the port if you need the check. - The default OkHttp client follows redirects from
httpstohttp; the other adapters never do.followSslRedirects(false)gives the same rule. - The Kotlin lines are compiled and tested with the
okhttp-adaptersmodule (they need OkHttp, which the other modules never carry); the Swift and TypeScript lines are checked like the rest of the cookbook.
Source: okhttp-adapters/README.md and ADR-060. The ports: Your network stack inside.