Skip to content
This repository was archived by the owner on Aug 13, 2024. It is now read-only.

Commit 1dfe6ad

Browse files
committed
refactor(connected-users): rework user fetching
- Fully tested and encapsulating the cache state in a class - Cleaner and more elegant implementation to handle cached users vs fetched users.
1 parent d8cec8d commit 1dfe6ad

File tree

4 files changed

+228
-48
lines changed

4 files changed

+228
-48
lines changed
Lines changed: 74 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,87 @@
11
/*
22
* Fetch users from the Stack Exchange API
33
*/
4-
import { LruCache } from '../utils'
4+
import { Cache, LruCache } from '../utils'
55
import { StackExchangeAPI } from '../seAPI'
66
import { minimalUserFilter, seAPIKey, userAPICacheSize } from '../constants'
77

8-
import { DeletedUser, ExistingUser, User } from './classes'
8+
import { DeletedUser, ExistingUser, JSONLoadable, User } from './classes'
99

10-
type JSONUser = Parameters<(typeof ExistingUser)['fromJSON']>[0]
11-
const apiCache = new LruCache<number, User>(userAPICacheSize)
12-
const api = new StackExchangeAPI(seAPIKey)
10+
type UserFetcherOptions = {
11+
api: StackExchangeAPI
12+
cache: Cache<User['user_id'], User>
13+
missingAssumeDeleted: boolean
14+
}
1315

14-
// Fetch users and yield User objects in the same order they are listed in the argument
15-
export async function* fetchUsers(
16-
userIds: number[],
17-
missingAssumeDeleted = false
18-
): AsyncGenerator<User, void, undefined> {
19-
// split into still-cached and to-be-fetched uids
20-
let toFetch: number[] = []
21-
const cached = new Map(
22-
userIds.reduce((resolved, uid) => {
23-
const user = apiCache.get(uid)
24-
if (user) return [...resolved, [uid, user]]
25-
toFetch.push(uid)
26-
return resolved
27-
}, [] as [number, User][])
28-
)
29-
// fetch the remaining uids in batches of 100
30-
while (toFetch.length > 0) {
31-
const queryIds = toFetch.splice(0, 100)
32-
toFetch = toFetch.splice(100)
33-
const results = await api.fetch<JSONUser>(
34-
`/users/{ids}`,
35-
{ ids: queryIds },
36-
{ filter: minimalUserFilter }
37-
)
16+
export class UserFetcher<
17+
UserType extends JSONLoadable<User> = typeof ExistingUser,
18+
MissingUser extends User = DeletedUser
19+
> {
20+
private readonly api: StackExchangeAPI
21+
private readonly cache: Cache<User['user_id'], User>
22+
readonly missingAssumeDeleted: boolean = false
23+
24+
constructor(
25+
private readonly UserClass: UserType,
26+
private readonly MissingClass: new (userId: User['user_id']) => MissingUser,
27+
options: UserFetcherOptions
28+
) {
29+
this.api = options.api
30+
this.cache = options.cache
31+
this.missingAssumeDeleted = options.missingAssumeDeleted
32+
}
33+
34+
static withDefaultClasses(
35+
options: Partial<UserFetcherOptions> = {}
36+
): UserFetcher {
37+
const config: UserFetcherOptions = {
38+
api: options.api || new StackExchangeAPI(seAPIKey),
39+
cache:
40+
options.cache || new LruCache<User['user_id'], User>(userAPICacheSize),
41+
missingAssumeDeleted:
42+
options.missingAssumeDeleted === undefined
43+
? false
44+
: options.missingAssumeDeleted,
45+
}
46+
return new UserFetcher(ExistingUser, DeletedUser, config)
47+
}
48+
49+
/** Fetch users and yield User objects in the same order they are listed in the argument */
50+
async *users(
51+
userIds: InstanceType<UserType>['user_id'][]
52+
): AsyncIterableIterator<User> {
53+
const toFetch: number[] = []
3854
const byUserId = new Map(
39-
results.map((user) => [user.user_id, ExistingUser.fromJSON(user)])
55+
userIds.reduce((resolved, uid) => {
56+
const user = this.cache.get(uid)
57+
if (user === undefined) toFetch.push(uid)
58+
return user !== undefined ? [...resolved, [uid, user]] : resolved
59+
}, [] as [User['user_id'], User][])
4060
)
41-
const lastFetched = userIds.indexOf(queryIds[queryIds.length - 1]) + 1
42-
yield* userIds.splice(0, lastFetched).reduce((mapped, uid) => {
43-
let user = cached.get(uid) || byUserId.get(uid)
44-
if (user === undefined && missingAssumeDeleted) {
45-
user = new DeletedUser(uid)
61+
if (toFetch.length > 0) {
62+
const apiResults = this.api.fetchAll<Parameters<UserType['fromJSON']>[0]>(
63+
'/users/{ids}',
64+
{ ids: toFetch },
65+
{ filter: minimalUserFilter }
66+
)
67+
for await (const jsonUser of apiResults) {
68+
const user = this.UserClass.fromJSON(jsonUser)
69+
this.cache.put(user.user_id, user)
70+
byUserId.set(user.user_id, user)
4671
}
47-
if (user) apiCache.put(uid, user)
48-
return user ? [...mapped, user] : mapped
49-
}, [])
50-
userIds = userIds.splice(lastFetched)
72+
}
73+
const get = this.missingAssumeDeleted
74+
? (uid: number) => byUserId.get(uid) || this.missingUser(uid)
75+
: (uid: number) => byUserId.get(uid)
76+
for (const uid of userIds) {
77+
const user = get(uid)
78+
if (user) yield user
79+
}
80+
}
81+
82+
private missingUser(userId: User['user_id']): MissingUser {
83+
const missing = new this.MissingClass(userId)
84+
this.cache.put(missing.user_id, missing)
85+
return missing
5186
}
52-
// yield any remaining cached users
53-
yield* userIds.map(
54-
(uid) =>
55-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
56-
apiCache.get(uid)!
57-
)
5887
}

scripts/connected-users/src/users/controller.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
/* global Stacks */
22
import { controllerId } from '../constants'
33

4-
import { fetchUsers } from './api'
4+
import { UserFetcher } from './api'
55

66
// set padding inside the user cards to 0 in the usercard lists
77
// _uc-p is the CSS variable set by Stacks and we can override it here.
88
const userStyles = `.s-${controllerId} .s-user-card { --_uc-p: 0 }`
9+
const api = UserFetcher.withDefaultClasses({ missingAssumeDeleted: true })
910

1011
export class UserListController extends Stacks.StacksController {
1112
static controllerId = `${controllerId}-user-list`
@@ -44,7 +45,7 @@ export class UserListController extends Stacks.StacksController {
4445
)
4546
if (hydrationRows.size === 0) return
4647
window.requestAnimationFrame(async () => {
47-
for await (const user of fetchUsers([...hydrationRows.keys()], true)) {
48+
for await (const user of api.users([...hydrationRows.keys()])) {
4849
const userRow = hydrationRows.get(user.user_id)
4950
if (!userRow) continue
5051
const firstChild =

scripts/connected-users/src/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ export const outlets = <T>({ controllerId }: HasControllerId): keyof T =>
4444
export const outletConnected = ({ controllerId }: HasControllerId): string =>
4545
`${camelize(controllerId)}OutletConnected`
4646

47-
export class LruCache<K, V> {
47+
export interface Cache<K, V> {
48+
get(key: K): V | undefined
49+
put(key: K, value: V): void
50+
}
51+
export class LruCache<K, V> implements Cache<K, V> {
4852
private values: Map<K, V> = new Map<K, V>()
4953
private maxEntries = 20
5054

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {
2+
afterEach,
3+
beforeEach,
4+
describe,
5+
jest,
6+
expect,
7+
test,
8+
} from '@jest/globals'
9+
10+
import { UserFetcher } from '@connected-users/users/api'
11+
import { DeletedUser, ExistingUser, User } from '@connected-users/users/classes'
12+
13+
async function* asAsyncIt<T>(arr: T[]): AsyncIterableIterator<T> {
14+
yield* arr
15+
}
16+
const kebab = (value: string) =>
17+
value
18+
.replace(/([a-z])([A-Z])/g, '$1-$2')
19+
.replace(/[\s_]+/g, '-')
20+
.toLowerCase()
21+
22+
const genUser = (id: number, name = 'User Name') => ({
23+
user_id: id,
24+
display_name: name,
25+
badge_counts: { gold: 0, silver: 0, bronze: 0 },
26+
link: `https://example.com/users/${id}/${kebab(name)}`,
27+
profile_image: `https://img.example.com/${id}.png`,
28+
reputation: 1,
29+
user_type: 'registered',
30+
is_employee: false,
31+
})
32+
33+
describe('We can fetch users in batches from the Stack Exchange API', () => {
34+
const MockedAPIClass = jest.createMockFromModule<
35+
typeof import('@connected-users/seAPI')
36+
>('@connected-users/seAPI').StackExchangeAPI
37+
const mockedAPI = new MockedAPIClass()
38+
const mockFetchAll = jest.mocked(mockedAPI.fetchAll)
39+
40+
const MockedCache = jest.createMockFromModule<
41+
typeof import('@connected-users/utils')
42+
>('@connected-users/utils').LruCache
43+
const mockedCache = new MockedCache<User['user_id'], User>()
44+
const mockCacheGet = jest.mocked(mockedCache.get)
45+
const mockCachePut = jest.mocked(mockedCache.put)
46+
47+
let fetcher: UserFetcher
48+
49+
afterEach(() => {
50+
jest.restoreAllMocks()
51+
})
52+
beforeEach(() => {
53+
fetcher = UserFetcher.withDefaultClasses({
54+
api: mockedAPI,
55+
cache: mockedCache,
56+
})
57+
})
58+
59+
test('with an empty cache, all users are fetched from the API', async () => {
60+
mockFetchAll.mockReturnValue(
61+
asAsyncIt([1, 2, 3].map((id) => genUser(id, `User ${id}`)))
62+
)
63+
const gen = fetcher.users([1, 2, 3])
64+
for await (const user of gen) {
65+
expect(user).toBeInstanceOf(ExistingUser)
66+
expect(mockCachePut).toHaveBeenCalledWith(user.user_id, user)
67+
}
68+
expect(mockFetchAll).toHaveBeenCalledWith(
69+
'/users/{ids}',
70+
{ ids: [1, 2, 3] },
71+
{ filter: expect.any(String) }
72+
)
73+
})
74+
75+
test('cached users skip the API', async () => {
76+
mockCacheGet.mockImplementation((id: number) =>
77+
Object.assign(new ExistingUser(), genUser(id, `User ${id}`))
78+
)
79+
const gen = fetcher.users([1, 2, 3])
80+
for await (const user of gen) {
81+
expect(user).toBeInstanceOf(ExistingUser)
82+
}
83+
expect(mockFetchAll).not.toHaveBeenCalled()
84+
expect(mockCachePut).not.toHaveBeenCalled()
85+
})
86+
87+
test('mixing cached and uncached users produces users in requested order', async () => {
88+
mockFetchAll.mockReturnValue(
89+
asAsyncIt([2, 4].map((id) => genUser(id, `User ${id}`)))
90+
)
91+
mockCacheGet.mockImplementation((id: number) =>
92+
[1, 3].includes(id)
93+
? Object.assign(new ExistingUser(), genUser(id, `User ${id}`))
94+
: undefined
95+
)
96+
97+
const gen = fetcher.users([1, 2, 3, 4])
98+
let lastId = 0
99+
for await (const user of gen) {
100+
expect(user).toBeInstanceOf(ExistingUser)
101+
expect(user.user_id).toBeGreaterThan(lastId)
102+
lastId = user.user_id
103+
}
104+
expect(lastId).toBe(4)
105+
expect(mockFetchAll).toHaveBeenCalledWith(
106+
'/users/{ids}',
107+
{ ids: [2, 4] },
108+
{ filter: expect.any(String) }
109+
)
110+
expect(mockCachePut.mock.calls).toEqual([
111+
[2, expect.any(ExistingUser)],
112+
[4, expect.any(ExistingUser)],
113+
])
114+
})
115+
116+
test('Missing user IDs are ignored ...', async () => {
117+
mockFetchAll.mockReturnValue(
118+
asAsyncIt([2, 4].map((id) => genUser(id, `User ${id}`)))
119+
)
120+
const gen = fetcher.users([1, 2, 3, 4])
121+
const seen: number[] = []
122+
for await (const user of gen) {
123+
expect(user).toBeInstanceOf(ExistingUser)
124+
seen.push(user.user_id)
125+
}
126+
expect(seen).toEqual([2, 4])
127+
})
128+
129+
test('... unless we configure the fetcher to treat those as deleted', async () => {
130+
mockFetchAll.mockReturnValue(
131+
asAsyncIt([2, 4].map((id) => genUser(id, `User ${id}`)))
132+
)
133+
const fetcher = UserFetcher.withDefaultClasses({
134+
api: mockedAPI,
135+
cache: mockedCache,
136+
missingAssumeDeleted: true,
137+
})
138+
const gen = fetcher.users([1, 2, 3, 4])
139+
const seen: number[] = []
140+
for await (const user of gen) {
141+
expect(user).toBeInstanceOf(user.user_id % 2 ? DeletedUser : ExistingUser)
142+
seen.push(user.user_id)
143+
}
144+
expect(seen).toEqual([1, 2, 3, 4])
145+
})
146+
})

0 commit comments

Comments
 (0)