Skip to content

Commit b71e7dc

Browse files
committed
sdk: request only requested /me fields
1 parent 350f79a commit b71e7dc

File tree

2 files changed

+144
-17
lines changed

2 files changed

+144
-17
lines changed

sdk/src/__tests__/database.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { afterEach, describe, expect, mock, test } from 'bun:test'
2+
3+
import { getUserInfoFromApiKey } from '../impl/database'
4+
5+
import type { Logger } from '@codebuff/common/types/contracts/logger'
6+
7+
describe('getUserInfoFromApiKey', () => {
8+
const originalFetch = globalThis.fetch
9+
10+
const createLoggerMocks = (): Logger =>
11+
({
12+
debug: mock(() => {}),
13+
info: mock(() => {}),
14+
warn: mock(() => {}),
15+
error: mock(() => {}),
16+
}) as unknown as Logger
17+
18+
afterEach(() => {
19+
globalThis.fetch = originalFetch
20+
mock.restore()
21+
})
22+
23+
test('requests only the requested fields (no implicit userColumns)', async () => {
24+
const fetchMock = mock(async (input: RequestInfo | URL) => {
25+
const urlString =
26+
input instanceof URL
27+
? input.toString()
28+
: input instanceof Request
29+
? input.url
30+
: String(input)
31+
const url = new URL(urlString)
32+
33+
expect(url.pathname).toContain('/api/v1/me')
34+
expect(url.searchParams.get('fields')).toBe('id')
35+
36+
return new Response(JSON.stringify({ id: 'user-123' }), { status: 200 })
37+
})
38+
globalThis.fetch = fetchMock as unknown as typeof fetch
39+
40+
const result = await getUserInfoFromApiKey({
41+
apiKey: 'test-api-key',
42+
fields: ['id'],
43+
logger: createLoggerMocks(),
44+
})
45+
46+
expect(fetchMock).toHaveBeenCalledTimes(1)
47+
expect(result).toEqual({ id: 'user-123' })
48+
})
49+
50+
test('merges cached fields and avoids refetching when present', async () => {
51+
const fetchMock = mock(async (input: RequestInfo | URL) => {
52+
const urlString =
53+
input instanceof URL
54+
? input.toString()
55+
: input instanceof Request
56+
? input.url
57+
: String(input)
58+
const url = new URL(urlString)
59+
const fields = url.searchParams.get('fields')
60+
61+
if (fields === 'id') {
62+
return new Response(JSON.stringify({ id: 'user-123' }), { status: 200 })
63+
}
64+
if (fields === 'email') {
65+
return new Response(JSON.stringify({ email: 'user@example.com' }), {
66+
status: 200,
67+
})
68+
}
69+
70+
throw new Error(`Unexpected fields param: ${fields}`)
71+
})
72+
globalThis.fetch = fetchMock as unknown as typeof fetch
73+
74+
const logger = createLoggerMocks()
75+
76+
const first = await getUserInfoFromApiKey({
77+
apiKey: 'cache-test-api-key',
78+
fields: ['id'],
79+
logger,
80+
})
81+
expect(first).toEqual({ id: 'user-123' })
82+
83+
const second = await getUserInfoFromApiKey({
84+
apiKey: 'cache-test-api-key',
85+
fields: ['email'],
86+
logger,
87+
})
88+
expect(second).toEqual({ email: 'user@example.com' })
89+
90+
const third = await getUserInfoFromApiKey({
91+
apiKey: 'cache-test-api-key',
92+
fields: ['id', 'email'],
93+
logger,
94+
})
95+
expect(third).toEqual({ id: 'user-123', email: 'user@example.com' })
96+
97+
expect(fetchMock).toHaveBeenCalledTimes(2)
98+
})
99+
})
100+

sdk/src/impl/database.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { validateSingleAgent } from '@codebuff/common/templates/agent-validation'
2-
import { userColumns } from '@codebuff/common/types/contracts/database'
32
import { DynamicAgentTemplateSchema } from '@codebuff/common/types/dynamic-agent-template'
43
import { getErrorObject } from '@codebuff/common/util/error'
54
import z from 'zod/v4'
@@ -19,9 +18,13 @@ import type {
1918
import type { DynamicAgentTemplate } from '@codebuff/common/types/dynamic-agent-template'
2019
import type { ParamsOf } from '@codebuff/common/types/function-params'
2120

21+
type CachedUserInfo = Partial<
22+
NonNullable<Awaited<GetUserInfoFromApiKeyOutput<UserColumn>>>
23+
>
24+
2225
const userInfoCache: Record<
2326
string,
24-
Awaited<GetUserInfoFromApiKeyOutput<UserColumn>> | null
27+
CachedUserInfo | null
2528
> = {}
2629

2730
const agentsResponseSchema = z.object({
@@ -34,20 +37,29 @@ export async function getUserInfoFromApiKey<T extends UserColumn>(
3437
): GetUserInfoFromApiKeyOutput<T> {
3538
const { apiKey, fields, logger } = params
3639

37-
if (apiKey in userInfoCache) {
38-
const userInfo = userInfoCache[apiKey]
39-
if (userInfo === null) {
40-
throw new AuthenticationError('Authentication failed', 401)
41-
}
42-
return Object.fromEntries(
43-
fields.map((field) => [field, userInfo[field]]),
44-
) as {
45-
[K in (typeof fields)[number]]: (typeof userInfo)[K]
46-
}
40+
const cached = userInfoCache[apiKey]
41+
if (cached === null) {
42+
throw new AuthenticationError('Authentication failed', 401)
4743
}
44+
if (
45+
cached &&
46+
fields.every((field) =>
47+
Object.prototype.hasOwnProperty.call(cached, field),
48+
)
49+
) {
50+
return Object.fromEntries(fields.map((field) => [field, cached[field]])) as {
51+
[K in T]: CachedUserInfo[K]
52+
} as Awaited<GetUserInfoFromApiKeyOutput<T>>
53+
}
54+
55+
const fieldsToFetch = cached
56+
? fields.filter(
57+
(field) => !Object.prototype.hasOwnProperty.call(cached, field),
58+
)
59+
: fields
4860

4961
const urlParams = new URLSearchParams({
50-
fields: userColumns.join(','),
62+
fields: fieldsToFetch.join(','),
5163
})
5264
const url = new URL(`/api/v1/me?${urlParams}`, WEBSITE_URL)
5365

@@ -100,8 +112,13 @@ export async function getUserInfoFromApiKey<T extends UserColumn>(
100112
throw new NetworkError('Request failed', ErrorCodes.UNKNOWN_ERROR, response.status)
101113
}
102114

115+
const cachedBeforeMerge = userInfoCache[apiKey]
103116
try {
104-
userInfoCache[apiKey] = await response.json()
117+
const fetchedFields = (await response.json()) as CachedUserInfo
118+
userInfoCache[apiKey] = {
119+
...(cachedBeforeMerge ?? {}),
120+
...fetchedFields,
121+
}
105122
} catch (error) {
106123
logger.error(
107124
{ error: getErrorObject(error), apiKey, fields },
@@ -114,11 +131,21 @@ export async function getUserInfoFromApiKey<T extends UserColumn>(
114131
if (userInfo === null) {
115132
throw new AuthenticationError('Authentication failed', 401)
116133
}
134+
if (
135+
!userInfo ||
136+
!fields.every((field) =>
137+
Object.prototype.hasOwnProperty.call(userInfo, field),
138+
)
139+
) {
140+
logger.error(
141+
{ apiKey, fields },
142+
'getUserInfoFromApiKey: response missing required fields',
143+
)
144+
throw new NetworkError('Request failed', ErrorCodes.UNKNOWN_ERROR, response.status)
145+
}
117146
return Object.fromEntries(
118147
fields.map((field) => [field, userInfo[field]]),
119-
) as {
120-
[K in (typeof fields)[number]]: (typeof userInfo)[K]
121-
}
148+
) as Awaited<GetUserInfoFromApiKeyOutput<T>>
122149
}
123150

124151
export async function fetchAgentFromDatabase(

0 commit comments

Comments
 (0)