diff --git a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt index ed263c40d4..948ab220dc 100644 --- a/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt +++ b/model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt @@ -4,6 +4,11 @@ package org.modelix.model.client2 import INodeJS import INodeReferenceJS +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.js.Js +import io.ktor.client.plugins.api.createClientPlugin +import io.ktor.http.HttpHeaders import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.await @@ -41,8 +46,70 @@ import kotlin.js.Promise import kotlin.time.Duration.Companion.seconds /** - * Same as [loadModelsFromJsonAsBranch] but directly returns the [MutableModelTreeJs.rootNode] of the created branch. + * Extracts the binding key from a request URL path. + * + * Binding keys have the form `{repositoryId}/{branchId}` and are extracted from the path pattern + * `/repositories/{repositoryId}/branches/{branchId}`. + * + * Returns `null` for requests that are not associated with a specific branch (e.g. `/v2/server-id`). + */ +internal fun extractBindingKey(path: String): String? { + val match = Regex("/repositories/([^/]+)/branches/([^/]+)").find(path) ?: return null + return "${match.groupValues[1]}/${match.groupValues[2]}" +} + +/** Configuration for [PerBindingAuthPlugin]. */ +internal class PerBindingAuthConfig { + var getToken: suspend (bindingKey: String?) -> String? = { null } +} + +/** + * Custom Ktor client plugin that adds `Authorization: Bearer ` headers per branch binding. + * + * For requests that target a specific repository/branch, the token is looked up by binding key. + * For other requests (e.g. `/v2/server-id`), the global token is used as a fallback. + * The token provider is invoked on **every** request, ensuring tokens are always fresh without + * relying on Ktor's internal bearer-token cache. */ +internal val PerBindingAuthPlugin = createClientPlugin("PerBindingAuth", ::PerBindingAuthConfig) { + onRequest { request, _ -> + val bindingKey = extractBindingKey(request.url.encodedPath) + val token = pluginConfig.getToken(bindingKey) + if (token != null) { + request.headers[HttpHeaders.Authorization] = "Bearer $token" + } + } +} + +/** + * A [ModelClientV2Builder] that installs [PerBindingAuthPlugin] to support per-binding tokens. + * + * The [tokenSelector] is called for every HTTP request. It receives the binding key + * (`{repositoryId}/{branchId}`) extracted from the request URL, or `null` for non-binding + * requests. The implementation should return the appropriate bearer token or `null` for + * unauthenticated requests. + */ +private class ClientJSInternalBuilder( + private val tokenSelector: suspend (bindingKey: String?) -> String?, +) : ModelClientV2Builder() { + override fun createHttpClient(): HttpClient { + return HttpClient(Js) { + configureHttpClient(this) + } + } + + override fun configureHttpClient(config: HttpClientConfig<*>) { + // Install the standard plugins (JSON, timeout, retry, compression). + // authConfig is intentionally kept null so ModelixAuthClient is NOT installed; + // authentication is handled exclusively by PerBindingAuthPlugin below. + super.configureHttpClient(config) + config.install(PerBindingAuthPlugin) { + getToken = tokenSelector + } + } +} + + @JsExport fun loadModelsFromJson(json: Array): INodeJS { val branch = loadModelsFromJsonAsBranch(json) @@ -70,19 +137,28 @@ fun loadModelsFromJsonAsBranch(json: Array): MutableModelTreeJs { * * @param url URL to the V2 endpoint of the model server. * e.g., http://localhost:28102/v2 + * @param bearerTokenProvider Optional global token provider used for requests that are not + * associated with a specific model binding (e.g. `/v2/server-id`). Per-binding tokens take + * precedence over this provider and can be supplied via + * [ReplicatedModelParameters.tokenProvider] when calling [ClientJS.startReplicatedModels]. */ @JsExport fun connectClient(url: String, bearerTokenProvider: (() -> Promise)? = null): Promise { return GlobalScope.promise { - val clientBuilder = ModelClientV2.builder() - .url(url) + val bindingTokenProviders = mutableMapOf String?>() + val globalToken: (suspend () -> String?)? = bearerTokenProvider?.let { bp -> { bp().await() } } + + val clientBuilder = ClientJSInternalBuilder { bindingKey -> + if (bindingKey != null) { + bindingTokenProviders[bindingKey]?.invoke() ?: globalToken?.invoke() + } else { + globalToken?.invoke() + } + }.url(url) - if (bearerTokenProvider != null) { - clientBuilder.authToken { bearerTokenProvider().await() } - } val client = clientBuilder.build() client.init() - return@promise ClientJSImpl(client) + return@promise ClientJSImpl(client, bindingTokenProviders) } } @@ -207,13 +283,33 @@ interface ClientJS { } @JsExport -data class ReplicatedModelParameters( +class ReplicatedModelParameters( val repositoryId: String, val branchId: String, val idScheme: IdSchemeJS, + /** + * Optional bearer-token provider for this specific binding. + * + * When provided, this callback is invoked on **every individual HTTP request** + * (each GET, POST, etc.) that targets the `repositoryId`/`branchId` combination — + * not just once per [startReplicatedModels] call. This ensures a fresh token is used + * for each request without relying on Ktor's internal bearer-token cache. It takes + * precedence over the global token provider supplied to [connectClient]. + * + * Pass `null` (or omit this parameter) to use the global token from [connectClient]. + */ + val tokenProvider: (() -> Promise)? = null, ) -internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS { +internal class ClientJSImpl( + private val modelClient: ModelClientV2, + /** + * Mutable map shared with [connectClient]'s [PerBindingAuthPlugin] token selector. + * Entries are added here when [startReplicatedModels] is called with per-binding token + * providers, making the plugin immediately route the right token for each branch's requests. + */ + private val bindingTokenProviders: MutableMap String?> = mutableMapOf(), +) : ClientJS { override fun setClientProvidedUserId(userId: String) { modelClient.setClientProvidedUserId(userId) @@ -288,6 +384,14 @@ internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS { override fun startReplicatedModels(parameters: Array): Promise { return GlobalScope.promise { val models = parameters.map { parameters -> + // Register a per-binding token provider if one was supplied with the parameters. + // The shared bindingTokenProviders map is read by PerBindingAuthPlugin on every + // HTTP request, so the registration takes effect immediately. + parameters.tokenProvider?.let { jsProvider -> + val key = "${parameters.repositoryId}/${parameters.branchId}" + bindingTokenProviders[key] = { jsProvider().await() } + } + val modelClient = modelClient val branchReference = RepositoryId(parameters.repositoryId).getBranchReference(parameters.branchId) val idGenerator: (TreeId) -> INodeIdGenerator = when (parameters.idScheme) { diff --git a/model-client/src/jsTest/kotlin/org/modelix/model/client2/ClientJSTest.kt b/model-client/src/jsTest/kotlin/org/modelix/model/client2/ClientJSTest.kt index 5aa69ba526..ee9eb687c5 100644 --- a/model-client/src/jsTest/kotlin/org/modelix/model/client2/ClientJSTest.kt +++ b/model-client/src/jsTest/kotlin/org/modelix/model/client2/ClientJSTest.kt @@ -3,6 +3,7 @@ package org.modelix.model.client2 import GeneratedConcept import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class ClientJSTest { @@ -57,4 +58,17 @@ class ClientJSTest { // Assert assertEquals(child0.getReferenceTargetNode("aReference"), child1) } + + @Test + fun extractBindingKey_returnsKeyForBranchRequest() { + val key = extractBindingKey("/v2/repositories/myRepo/branches/main/delta") + assertEquals("myRepo/main", key) + } + + @Test + fun extractBindingKey_returnsNullForNonBranchRequest() { + assertNull(extractBindingKey("/v2/server-id")) + assertNull(extractBindingKey("/v2/client-id")) + assertNull(extractBindingKey("/v2/repositories/myRepo")) + } } diff --git a/vue-model-api/src/useModelClient.ts b/vue-model-api/src/useModelClient.ts index 5190d01506..d1212967e1 100644 --- a/vue-model-api/src/useModelClient.ts +++ b/vue-model-api/src/useModelClient.ts @@ -11,9 +11,10 @@ type ClientJS = org.modelix.model.client2.ClientJS; * Creates a model client for a given URL. * * The URL is reactive and if it changes, the client is automatically disposed and a new client for the updated URL is created. + * If the URL is `null` or `undefined`, no client is created. * - * @param url - Reactive reference of an URL to a model server. - * @param getClient - Function how to create a cliente given an URL. + * @param url - Reactive reference of a URL to a model server. When `null` or `undefined`, no client is created. + * @param getClient - Function how to create a client given an URL. * * Defaults to connecting directly to the modelix model server under the given URL. * @@ -23,7 +24,7 @@ type ClientJS = org.modelix.model.client2.ClientJS; * @returns {Ref} values.error Reactive reference to a client connection error. */ export function useModelClient( - url: MaybeRefOrGetter, + url: MaybeRefOrGetter, getClient: (url: string) => Promise = connectClient, ): { client: Ref; @@ -44,7 +45,9 @@ export function useModelClient( useLastPromiseEffect( () => { dispose(); - return getClient(toValue(url)); + const urlValue = toValue(url); + if (!urlValue) return undefined; + return getClient(urlValue); }, (createdClient, isResultOfLastStartedPromise) => { if (isResultOfLastStartedPromise) { diff --git a/vue-model-api/src/useReplicatedModels.test.ts b/vue-model-api/src/useReplicatedModels.test.ts index 92f8842380..a294df5afa 100644 --- a/vue-model-api/src/useReplicatedModels.test.ts +++ b/vue-model-api/src/useReplicatedModels.test.ts @@ -182,3 +182,125 @@ describe("does not start model", () => { expect(startReplicatedModels).not.toHaveBeenCalled(); }); }); + +describe("URL-based client with getToken", () => { + // Shared mock for startReplicatedModels across all tests in this describe block. + const startReplicatedModels = jest.fn((parameters: ReplicatedModelParameters[]) => { + const branchId = parameters[0]?.branchId ?? "defaultBranch"; + return Promise.resolve( + new SuccessfulReplicatedModelJS(branchId) as unknown as ReplicatedModelJS, + ); + }); + + class MockClientJS { + startReplicatedModels( + parameters: ReplicatedModelParameters[], + ): Promise { + return startReplicatedModels(parameters); + } + } + + let mockClientInstance: MockClientJS; + + // createClient mock — returns the same client instance every call (simulating + // a shared, reused ClientJS per URL). + let createClient: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientInstance = new MockClientJS(); + createClient = jest.fn((_url: string) => + Promise.resolve(mockClientInstance as unknown as ClientJS), + ); + }); + + test("each model param gets its own tokenProvider wrapping getToken", async () => { + const params = new ReplicatedModelParameters( + "aRepository", + "aBranch", + IdSchemeJS.MODELIX, + ); + const getToken = jest.fn(() => Promise.resolve("token-for-aBranch")); + + useReplicatedModels("https://model-server/v2", [params], getToken, createClient); + + // Wait for the async client creation and model connection to settle. + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + expect(startReplicatedModels).toHaveBeenCalledTimes(1); + const calledWith = startReplicatedModels.mock.calls[0][0]; + + // Each param should have tokenProvider set. + expect(typeof calledWith[0].tokenProvider).toBe("function"); + expect(calledWith[0].repositoryId).toBe("aRepository"); + expect(calledWith[0].branchId).toBe("aBranch"); + + // Invoking tokenProvider should delegate to getToken with the original param. + await calledWith[0].tokenProvider(); + expect(getToken).toHaveBeenCalledWith(params); + }); + + test("the same ClientJS instance is reused when only models change", async () => { + const models = ref([ + new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX), + ]); + const getToken = jest.fn(() => Promise.resolve("a-token")); + + useReplicatedModels("https://model-server/v2", models, getToken, createClient); + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + // createClient should have been called exactly once for the URL. + expect(createClient).toHaveBeenCalledTimes(1); + expect(startReplicatedModels).toHaveBeenCalledTimes(1); + + // Switch the branch — this should reuse the existing ClientJS. + models.value = [ + new ReplicatedModelParameters( + "aRepository", + "aNewBranch", + IdSchemeJS.MODELIX, + ), + ]; + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + // Still only one client creation — the existing client is shared. + expect(createClient).toHaveBeenCalledTimes(1); + // startReplicatedModels should be called again for the new branch. + expect(startReplicatedModels).toHaveBeenCalledTimes(2); + // The second call should carry the new branch params with tokenProvider. + const secondCallParams = startReplicatedModels.mock.calls[1][0]; + expect(secondCallParams[0].branchId).toBe("aNewBranch"); + expect(typeof secondCallParams[0].tokenProvider).toBe("function"); + }); + + test("a new ClientJS is created when the URL changes", async () => { + const url = ref("https://model-server/v2"); + const getToken = jest.fn(() => Promise.resolve("a-token")); + + useReplicatedModels( + url, + [new ReplicatedModelParameters("aRepository", "aBranch", IdSchemeJS.MODELIX)], + getToken, + createClient, + ); + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + expect(createClient).toHaveBeenCalledTimes(1); + expect(createClient).toHaveBeenCalledWith("https://model-server/v2"); + + url.value = "https://other-server/v2"; + + await new Promise(process.nextTick); + await new Promise(process.nextTick); + + expect(createClient).toHaveBeenCalledTimes(2); + expect(createClient).toHaveBeenNthCalledWith(2, "https://other-server/v2"); + }); +}); diff --git a/vue-model-api/src/useReplicatedModels.ts b/vue-model-api/src/useReplicatedModels.ts index aa0e89b37e..e6cc12a93d 100644 --- a/vue-model-api/src/useReplicatedModels.ts +++ b/vue-model-api/src/useReplicatedModels.ts @@ -1,4 +1,4 @@ -import type { org } from "@modelix/model-client"; +import { org } from "@modelix/model-client"; import type { INodeJS } from "@modelix/ts-model-api"; import { useLastPromiseEffect } from "./internal/useLastPromiseEffect"; import type { MaybeRefOrGetter, Ref } from "vue"; @@ -7,6 +7,7 @@ import type { ReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { toReactiveINodeJS } from "./internal/ReactiveINodeJS"; import { Cache } from "./internal/Cache"; import { handleChange } from "./internal/handleChange"; +import { useModelClient } from "./useModelClient"; type ClientJS = org.modelix.model.client2.ClientJS; type ReplicatedModelJS = org.modelix.model.client2.ReplicatedModelJS; @@ -14,6 +15,9 @@ type ChangeJS = org.modelix.model.client2.ChangeJS; type ReplicatedModelParameters = org.modelix.model.client2.ReplicatedModelParameters; +const { ReplicatedModelParameters: ReplicatedModelParametersCtor } = + org.modelix.model.client2; + function isDefined(value: T | null | undefined): value is T { return value !== null && value !== undefined; } @@ -29,8 +33,25 @@ function isDefined(value: T | null | undefined): value is T { * * Calling the returned dispose function stops syncing the root nodes to the underlying branches on the server. * - * @param client - Reactive reference of a client to a model server. - * @param models - Reactive reference to an array of ReplicatedModelParameters. + * When a server URL string is passed as `client`, a single {@link ClientJS} is created and + * **shared across all models and across branch switches** via {@link useModelClient}. This + * preserves the version cache so that switching branches only fetches the delta, and cross-model + * references continue to resolve correctly. + * + * If `getToken` is also supplied, a fresh token is obtained by calling `getToken(params)` before + * every HTTP request for that binding via {@link ReplicatedModelParameters.tokenProvider}. Each + * model in the `models` array receives an independent token provider, so concurrent connections + * to different repositories or branches each use their own credentials. + * + * @param client - Reactive reference of a client to a model server, or a server URL string. + * When a URL string is provided a {@link ClientJS} is created internally via + * {@link useModelClient} and reused until the URL changes. + * @param models - Reactive reference to an array of {@link ReplicatedModelParameters}. + * @param getToken - Optional callback that returns a bearer token for a given + * {@link ReplicatedModelParameters}. Only used when `client` is a URL string. Called on every + * HTTP request for the corresponding binding so the token is always fresh. + * @param createClient - Internal seam for testing; defaults to {@link connectClient} via + * {@link useModelClient}. * * @returns {Object} values Wrapper around different returned values. * @returns {Ref} values.replicatedModel Reactive reference to the replicated model for the specified branches. @@ -39,49 +60,110 @@ function isDefined(value: T | null | undefined): value is T { * @returns {Ref} values.error Reactive reference to a connection error. */ export function useReplicatedModels( - client: MaybeRefOrGetter, + client: MaybeRefOrGetter, models: MaybeRefOrGetter, + getToken?: (params: ReplicatedModelParameters) => Promise, + createClient?: (url: string) => Promise, ): { replicatedModel: Ref; rootNodes: Ref; dispose: () => void; error: Ref; } { - // Use `replicatedModel` to access the replicated model without tracking overhead of Vue.js. + // --------------------------------------------------------------------------- + // URL-based client lifecycle (mirrors useModelClient internally). + // When `client` is a URL string, useModelClient creates and disposes the + // ClientJS automatically when the URL changes — preserving the version cache + // across branch switches and enabling cross-model reference resolution. + // When `client` is a ClientJS, the getter always returns null so no + // extra client is managed here. + // --------------------------------------------------------------------------- + const { client: urlClientRef, dispose: disposeUrlClient } = useModelClient( + () => { + const c = toValue(client); + return typeof c === "string" ? c : null; + }, + createClient, + ); + + // --------------------------------------------------------------------------- + // Replicated-model state + // --------------------------------------------------------------------------- let replicatedModel: ReplicatedModelJS | null = null; const replicatedModelRef: Ref = shallowRef(null); const rootNodesRef: Ref = shallowRef([]); const errorRef: Ref = shallowRef(null); - const dispose = () => { - // Using `replicatedModelRef.value` here would create a circular dependency. - // `toRaw` does not work on `Ref<>`. + const disposeReplicatedModel = () => { if (replicatedModel !== null) { replicatedModel.dispose(); } + replicatedModel = null; replicatedModelRef.value = null; rootNodesRef.value = []; errorRef.value = null; }; + const dispose = () => { + disposeReplicatedModel(); + disposeUrlClient(); + }; + + // --------------------------------------------------------------------------- + // Effect: connect/reconnect when client or models change. + // Re-runs whenever `client`, `urlClientRef` (the resolved URL client), or + // `models` changes. + // --------------------------------------------------------------------------- useLastPromiseEffect<{ replicatedModel: ReplicatedModelJS; cache: Cache; }>( () => { - dispose(); - const clientValue = toValue(client); - if (!isDefined(clientValue)) { + disposeReplicatedModel(); + + const clientOrUrl = toValue(client); + if (!isDefined(clientOrUrl)) { return; } + const modelsValue = toValue(models); if (!isDefined(modelsValue)) { return; } + + let resolvedClient: ClientJS; + + if (typeof clientOrUrl === "string") { + // Reactive read — re-runs this effect when the URL client becomes ready. + const urlClient = urlClientRef.value; + if (urlClient === null) { + return; // Client not yet ready; will re-run once useModelClient resolves. + } + resolvedClient = urlClient; + } else { + resolvedClient = clientOrUrl; + } + + // Attach per-binding token providers. Each model receives an independent + // provider callback so concurrent connections to different branches each + // use their own fresh credentials. + const paramsWithTokens = + getToken !== undefined + ? modelsValue.map( + (p) => + new ReplicatedModelParametersCtor( + p.repositoryId, + p.branchId, + p.idScheme, + () => getToken(p), + ), + ) + : modelsValue; + const cache = new Cache(); - return clientValue - .startReplicatedModels(modelsValue) - .then((replicatedModel) => ({ replicatedModel, cache })); + return resolvedClient + .startReplicatedModels(paramsWithTokens) + .then((rm) => ({ replicatedModel: rm, cache })); }, ( { replicatedModel: connectedReplicatedModel, cache },