Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 113 additions & 9 deletions model-client/src/jsMain/kotlin/org/modelix/model/client2/ClientJS.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <token>` 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<String>): INodeJS {
val branch = loadModelsFromJsonAsBranch(json)
Expand Down Expand Up @@ -70,19 +137,28 @@ fun loadModelsFromJsonAsBranch(json: Array<String>): 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<String?>)? = null): Promise<ClientJS> {
return GlobalScope.promise {
val clientBuilder = ModelClientV2.builder()
.url(url)
val bindingTokenProviders = mutableMapOf<String, suspend () -> 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)
}
}

Expand Down Expand Up @@ -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<String?>)? = 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, suspend () -> String?> = mutableMapOf(),
) : ClientJS {

override fun setClientProvidedUserId(userId: String) {
modelClient.setClientProvidedUserId(userId)
Expand Down Expand Up @@ -288,6 +384,14 @@ internal class ClientJSImpl(private val modelClient: ModelClientV2) : ClientJS {
override fun startReplicatedModels(parameters: Array<ReplicatedModelParameters>): Promise<ReplicatedModelJS> {
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<INodeReference> = when (parameters.idScheme) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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"))
}
}
11 changes: 7 additions & 4 deletions vue-model-api/src/useModelClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -23,7 +24,7 @@ type ClientJS = org.modelix.model.client2.ClientJS;
* @returns {Ref<unknown>} values.error Reactive reference to a client connection error.
*/
export function useModelClient(
url: MaybeRefOrGetter<string>,
url: MaybeRefOrGetter<string | null | undefined>,
getClient: (url: string) => Promise<ClientJS> = connectClient,
): {
client: Ref<ClientJS | null>;
Expand All @@ -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) {
Expand Down
122 changes: 122 additions & 0 deletions vue-model-api/src/useReplicatedModels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReplicatedModelJS> {
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<ReplicatedModelParameters[]>([
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");
});
});
Loading