Skip to content

Commit 779a4b2

Browse files
authored
accept client providers via Observable/AsyncGenerator (#192)
This lets that value change after initial client creation.
1 parent 2c69441 commit 779a4b2

File tree

9 files changed

+258
-26
lines changed

9 files changed

+258
-26
lines changed

client/vscode-lib/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openctx/vscode-lib",
3-
"version": "0.0.17",
3+
"version": "0.0.18",
44
"description": "OpenCtx library for VS Code extensions",
55
"license": "Apache-2.0",
66
"repository": {

client/vscode-lib/src/controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ export function createController({
6565
outputChannel: vscode.OutputChannel
6666
getAuthInfo?: (secrets: vscode.SecretStorage, providerUri: string) => Promise<AuthInfo | null>
6767
features: { annotations?: boolean; statusBar?: boolean }
68-
providers?: ImportedProviderConfiguration[]
68+
providers?:
69+
| ImportedProviderConfiguration[]
70+
| Observable<ImportedProviderConfiguration[]>
71+
| (() => AsyncGenerator<ImportedProviderConfiguration[]>)
6972
mergeConfiguration?: (configuration: ClientConfiguration) => Promise<ClientConfiguration>
7073
preloadDelay?: number
7174
}): {

lib/client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openctx/client",
3-
"version": "0.0.22",
3+
"version": "0.0.23",
44
"description": "OpenCtx client library",
55
"license": "Apache-2.0",
66
"repository": {

lib/client/src/api.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
combineLatest,
1717
defer,
1818
distinctUntilChanged,
19+
filter,
1920
from,
2021
map,
2122
mergeMap,
@@ -72,14 +73,22 @@ function observeProviderCall<R>(
7273
fn: (provider: ProviderClientWithSettings) => Observable<R[] | null>,
7374
{ emitPartial, errorHook, logger }: Pick<ClientEnv<never>, 'logger'> & ObserveOptions,
7475
): Observable<EachWithProviderUri<R[]>> {
76+
// This sentinel value lets us avoid emitting a "fake" partial result when `emitPartial` is
77+
// true. We use `combineLatest` below, which waits until all providers have emitted until it
78+
// emits a value, so we need to use `startWith(null)`. But this means that upon subscription, we
79+
// get a meaningless `[null, null, null, ...]` value. Using this sentinel value instead of
80+
// `null` lets us detect that case and filter it out so our caller doesn't see it. But in the
81+
// case where there are no providers, we still want to emit [] because that is a true result.
82+
const EMIT_PARTIAL_SENTINEL: 'emit-partial-sentinel' = {} as any
83+
7584
return providerClients.pipe(
7685
mergeMap(providerClients =>
7786
providerClients && providerClients.length > 0
7887
? combineLatest(
7988
providerClients.map(({ uri, providerClient, settings }) =>
8089
defer(() => fn({ uri, providerClient, settings }))
8190
.pipe(
82-
emitPartial ? startWith(null) : tap(),
91+
emitPartial ? startWith(EMIT_PARTIAL_SENTINEL) : tap(),
8392
catchError(error => {
8493
if (errorHook) {
8594
errorHook(uri, error)
@@ -92,14 +101,24 @@ function observeProviderCall<R>(
92101
)
93102
.pipe(
94103
map(items =>
95-
(items || []).map(item => ({ ...item, providerUri: uri })),
104+
items === EMIT_PARTIAL_SENTINEL
105+
? items
106+
: (items || []).map(item => ({ ...item, providerUri: uri })),
96107
),
97108
),
98109
),
99110
)
100111
: of([]),
101112
),
102-
map(result => result.filter((v): v is EachWithProviderUri<R[]> => v !== null).flat()),
113+
filter(
114+
result =>
115+
!emitPartial || result.length === 0 || result.some(v => v !== EMIT_PARTIAL_SENTINEL),
116+
),
117+
map(result =>
118+
result
119+
.filter((v): v is EachWithProviderUri<R[]> => v !== null && v !== EMIT_PARTIAL_SENTINEL)
120+
.flat(),
121+
),
103122
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
104123
tap(items => {
105124
if (LOG_VERBOSE) {

lib/client/src/client/client.test.ts

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Observable, firstValueFrom, of } from 'rxjs'
44
import { TestScheduler } from 'rxjs/testing'
55
import { describe, expect, test } from 'vitest'
66
import type { Annotation, EachWithProviderUri } from '../api.js'
7-
import type { ConfigurationUserInput } from '../configuration.js'
7+
import type { ConfigurationUserInput, ImportedProviderConfiguration } from '../configuration.js'
88
import { type Client, type ClientEnv, createClient } from './client.js'
99

1010
function testdataFileUri(file: string): string {
@@ -45,6 +45,48 @@ describe('Client', () => {
4545
new TestScheduler((actual, expected) => expect(actual).toStrictEqual(expected))
4646

4747
describe('meta', () => {
48+
test('meta with async generator providers option', async () => {
49+
const client = createTestClient({
50+
configuration: () => of({}),
51+
providers: async function* (): AsyncGenerator<ImportedProviderConfiguration[]> {
52+
yield [
53+
{
54+
provider: { meta: () => ({ name: 'my-provider-1' }) },
55+
providerUri: 'u1',
56+
settings: true,
57+
},
58+
]
59+
await new Promise(resolve => setTimeout(resolve))
60+
yield [
61+
{
62+
provider: { meta: () => ({ name: 'my-provider-2' }) },
63+
providerUri: 'u2',
64+
settings: true,
65+
},
66+
{
67+
provider: { meta: () => ({ name: 'my-provider-3' }) },
68+
providerUri: 'u3',
69+
settings: true,
70+
},
71+
]
72+
},
73+
})
74+
75+
const values: EachWithProviderUri<MetaResult[]>[] = []
76+
const signal = new AbortController().signal
77+
for await (const value of client.metaChanges__asyncGenerator({}, {}, signal)) {
78+
values.push(value)
79+
}
80+
expect(values).toStrictEqual<typeof values>([
81+
[{ name: 'my-provider-1', providerUri: 'u1' }],
82+
[{ name: 'my-provider-2', providerUri: 'u2' }],
83+
[
84+
{ name: 'my-provider-2', providerUri: 'u2' },
85+
{ name: 'my-provider-3', providerUri: 'u3' },
86+
],
87+
])
88+
})
89+
4890
test('metaChanges__asyncGenerator', async () => {
4991
const client = createTestClient({
5092
configuration: () =>
@@ -54,11 +96,13 @@ describe('Client', () => {
5496
enable: true,
5597
providers: { [testdataFileUri('simpleMeta.js')]: { nameSuffix: '1' } },
5698
})
57-
observer.next({
58-
enable: true,
59-
providers: { [testdataFileUri('simpleMeta.js')]: { nameSuffix: '2' } },
99+
setTimeout(() => {
100+
observer.next({
101+
enable: true,
102+
providers: { [testdataFileUri('simpleMeta.js')]: { nameSuffix: '2' } },
103+
})
104+
observer.complete()
60105
})
61-
observer.complete()
62106
}),
63107
})
64108

@@ -68,7 +112,6 @@ describe('Client', () => {
68112
values.push(value)
69113
}
70114
expect(values).toStrictEqual<typeof values>([
71-
[],
72115
[{ name: 'simpleMeta-1', providerUri: testdataFileUri('simpleMeta.js') }],
73116
[{ name: 'simpleMeta-2', providerUri: testdataFileUri('simpleMeta.js') }],
74117
])
@@ -118,8 +161,7 @@ describe('Client', () => {
118161
}),
119162
},
120163
}).itemsChanges(FIXTURE_ITEMS_PARAMS),
121-
).toBe('(0a)', {
122-
'0': [],
164+
).toBe('a', {
123165
a: [{ ...fixtureItem('a'), providerUri: testdataFileUri('simple.js') }],
124166
} satisfies Record<string, EachWithProviderUri<Item[]>>)
125167
})
@@ -166,8 +208,7 @@ describe('Client', () => {
166208
getProviderClient: () => ({ annotations: () => of([fixtureAnn('a')]) }),
167209
},
168210
}).annotationsChanges(FIXTURE_ANNOTATIONS_PARAMS),
169-
).toBe('(0a)', {
170-
'0': [],
211+
).toBe('a', {
171212
a: [{ ...fixtureAnn('a'), providerUri: testdataFileUri('simple.js') }],
172213
} satisfies Record<string, EachWithProviderUri<Annotation[]>>)
173214
})

lib/client/src/client/client.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
distinctUntilChanged,
2121
firstValueFrom,
2222
from,
23+
isObservable,
2324
map,
2425
mergeMap,
2526
of,
@@ -45,7 +46,7 @@ import {
4546
} from '../configuration.js'
4647
import type { Logger } from '../logger.js'
4748
import { type ProviderClient, createProviderClient } from '../providerClient/createProviderClient.js'
48-
import { observableToAsyncGenerator } from './util.js'
49+
import { asyncGeneratorToObservable, observableToAsyncGenerator } from './util.js'
4950

5051
/**
5152
* Hooks for the OpenCtx {@link Client} to access information about the environment, such as the
@@ -75,7 +76,10 @@ export interface ClientEnv<R extends Range> {
7576
/**
7677
* The list of providers already resolved and imported.
7778
*/
78-
providers?: ImportedProviderConfiguration[]
79+
providers?:
80+
| ImportedProviderConfiguration[]
81+
| Observable<ImportedProviderConfiguration[]>
82+
| (() => AsyncGenerator<ImportedProviderConfiguration[]>)
7983

8084
/**
8185
* The authentication info for the provider.
@@ -337,17 +341,22 @@ export function createClient<R extends Range>(env: ClientEnv<R>): Client<R> {
337341
}
338342

339343
function providerClientsWithSettings(resource?: string): Observable<ProviderClientWithSettings[]> {
340-
return from(env.configuration(resource))
344+
const providers = isObservable(env.providers)
345+
? env.providers
346+
: env.providers instanceof Function
347+
? asyncGeneratorToObservable(env.providers())
348+
: of(env.providers)
349+
return combineLatest([env.configuration(resource), providers])
341350
.pipe(
342-
map(config => {
351+
map(([config, providers]) => {
343352
if (!config.enable) {
344353
config = { ...config, providers: {} }
345354
}
346-
return configurationFromUserInput(config, env.providers)
355+
return [configurationFromUserInput(config, providers), providers] as const
347356
}),
348357
)
349358
.pipe(
350-
mergeMap(configuration =>
359+
mergeMap(([configuration, providers]) =>
351360
configuration.providers.length > 0
352361
? combineLatest(
353362
configuration.providers.map(({ providerUri, settings }) =>
@@ -363,7 +372,7 @@ export function createClient<R extends Range>(env: ClientEnv<R>): Client<R> {
363372
logger,
364373
importProvider: env.importProvider,
365374
},
366-
env.providers?.find(
375+
providers?.find(
367376
provider => provider.providerUri === providerUri,
368377
)?.provider,
369378
),

lib/client/src/client/util.test.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Observable } from 'rxjs'
22
import { describe, expect, test } from 'vitest'
3-
import { observableToAsyncGenerator } from './util.js'
3+
import { asyncGeneratorToObservable, isAsyncGenerator, observableToAsyncGenerator } from './util.js'
44

55
describe('observableToAsyncGenerator', () => {
66
test('observable that emits and completes', async () => {
@@ -69,3 +69,124 @@ describe('observableToAsyncGenerator', () => {
6969
expect(results).toEqual([1, 2, 3, 4, 5])
7070
})
7171
})
72+
describe('asyncGeneratorToObservable', () => {
73+
function readObservable(observable: Observable<number>): Promise<number[]> {
74+
return new Promise<number[]>(resolve => {
75+
const results: number[] = []
76+
observable.subscribe({
77+
next: value => results.push(value),
78+
complete: () => resolve(results),
79+
})
80+
})
81+
}
82+
83+
test('async generator that yields and completes', async () => {
84+
const observable = asyncGeneratorToObservable(
85+
(async function* () {
86+
yield 1
87+
yield 2
88+
yield 3
89+
})(),
90+
)
91+
expect(await readObservable(observable)).toEqual([1, 2, 3])
92+
})
93+
94+
test('async generator that throws an error', async () => {
95+
const ERROR = new Error('Test error')
96+
async function* generator() {
97+
yield 1
98+
throw ERROR
99+
}
100+
101+
const observable = asyncGeneratorToObservable(generator())
102+
const results: number[] = []
103+
let error: Error | null = null
104+
105+
await new Promise<void>(resolve => {
106+
observable.subscribe({
107+
next: value => results.push(value),
108+
error: err => {
109+
error = err
110+
resolve()
111+
},
112+
complete: () => resolve(),
113+
})
114+
})
115+
116+
expect(results).toEqual([1])
117+
expect(error).toBe(ERROR)
118+
})
119+
120+
test('async generator with no yields', async () => {
121+
async function* generator() {
122+
// Empty generator
123+
}
124+
125+
const observable = asyncGeneratorToObservable(generator())
126+
let completed = false
127+
128+
await new Promise<void>(resolve => {
129+
observable.subscribe({
130+
next: () => expect.fail('should not yield any values'),
131+
complete: () => {
132+
completed = true
133+
resolve()
134+
},
135+
})
136+
})
137+
138+
expect(completed).toBe(true)
139+
})
140+
})
141+
142+
describe('isAsyncGenerator', () => {
143+
test('true for valid async generator', () => {
144+
async function* validAsyncGenerator() {
145+
yield 1
146+
}
147+
expect(isAsyncGenerator(validAsyncGenerator())).toBe(true)
148+
})
149+
150+
test('false for other values', () => {
151+
expect(isAsyncGenerator(42)).toBe(false)
152+
expect(isAsyncGenerator('string')).toBe(false)
153+
expect(isAsyncGenerator(true)).toBe(false)
154+
expect(isAsyncGenerator(undefined)).toBe(false)
155+
expect(isAsyncGenerator(null)).toBe(false)
156+
expect(isAsyncGenerator({})).toBe(false)
157+
expect(isAsyncGenerator(function regularFunction() {})).toBe(false)
158+
})
159+
160+
test('false for async functions', () => {
161+
async function asyncFunction() {}
162+
expect(isAsyncGenerator(asyncFunction)).toBe(false)
163+
})
164+
165+
test('false for non-async generator functions', () => {
166+
function* generatorFunction() {
167+
yield 1
168+
}
169+
expect(isAsyncGenerator(generatorFunction())).toBe(false)
170+
})
171+
172+
test('false for objects with some but not all required methods', () => {
173+
const incompleteObject = {
174+
next: () => {},
175+
throw: () => {},
176+
[Symbol.asyncIterator]: function () {
177+
return this
178+
},
179+
}
180+
expect(isAsyncGenerator(incompleteObject)).toBe(false)
181+
})
182+
183+
test('false for objects with all methods but incorrect Symbol.asyncIterator implementation', () => {
184+
const incorrectObject = {
185+
next: () => {},
186+
throw: () => {},
187+
return: () => {},
188+
[Symbol.asyncIterator]: () => ({}),
189+
}
190+
expect(isAsyncGenerator(incorrectObject)).toBe(false)
191+
})
192+
})

0 commit comments

Comments
 (0)