@lokalise/expert-api-s2s-client 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # Lokalise Expert API Client for Service-to-Service communication
2
+
3
+ FIXME: describe why it is here, not a separate repo, not a Harmony package, not combined with packages/backend-plugins/populate-expert-user/clients/BackendExpertHttpClient, not combined with Lokalise Node.js API client
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@lokalise/expert-api-s2s-client",
3
+ "version": "1.0.1",
4
+ "files": [
5
+ "src"
6
+ ],
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "exports": {
10
+ ".": "./dist/index.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "scripts": {
14
+ "build": "npm run clean && tsc --project tsconfig.build.json",
15
+ "clean": "rimraf dist",
16
+ "lint": "biome check . && tsc",
17
+ "lint:fix": "biome check --write",
18
+ "test": "vitest run --coverage",
19
+ "package-version": "echo $npm_package_version",
20
+ "prepublishOnly": "npm run build",
21
+ "postversion": "biome check --write package.json"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "dependencies": {
27
+ "@lokalise/backend-expert-http-client": "*",
28
+ "@lokalise/backend-http-client": "^7.0.0",
29
+ "@lokalise/fastify-extras": "^29.0.0",
30
+ "@lokalise/node-api": "^14.2.0",
31
+ "@lokalise/common-api-schemas": "^1.1.0",
32
+ "@lokalise/workspaces-api-schemas": "^1.1.0",
33
+ "openid-client": "^6.3.4",
34
+ "zod": "^3.25.76"
35
+ },
36
+ "devDependencies": {
37
+ "@biomejs/biome": "^2.0.0",
38
+ "@lokalise/biome-config": "^3.0.0",
39
+ "@lokalise/tsconfig": "^1.3.0",
40
+ "@vitest/coverage-v8": "^3.0.9",
41
+ "mockttp": "^4.1.0",
42
+ "rimraf": "^6.0.1",
43
+ "typescript": "5.9.2",
44
+ "vitest": "^3.2.4"
45
+ },
46
+ "peerDependencies": {
47
+ "mockttp": "^4.1.0"
48
+ }
49
+ }
@@ -0,0 +1,77 @@
1
+ import type { RequestContext } from '@lokalise/fastify-extras'
2
+ import type { ProjectStatistics } from '@lokalise/node-api'
3
+ import type { GetExpertProjectData } from '@lokalise/workspaces-api-schemas'
4
+
5
+ /**
6
+ * Represents the Projects API response type.
7
+ *
8
+ * This type extends the original `Project` object with additional fields.
9
+ * Some of these fields are currently mocked and not part of the actual API response,
10
+ * while others exist in the API but are not declared in the Node.js SDK types.
11
+ *
12
+ * We're using a direct type declaration instead of a Zod schema because we plan
13
+ * to eventually remove the internal API client in favor of the public Node.js SDK.
14
+ * Since the SDK types are not based on Zod, this approach ensures a smoother transition.
15
+ */
16
+ export type GetProjectResponseExtendedVariant = Omit<GetExpertProjectData, 'statistics'> & {
17
+ base_project_language_uuid: string
18
+ // Redefining statistics to add missing properties in languages field, which are not present in the original Project type.
19
+ statistics: Omit<ProjectStatistics, 'languages'> & {
20
+ languages: ProjectLanguageExtendedVariant[]
21
+ }
22
+ }
23
+
24
+ type ProjectLanguageExtendedVariant = {
25
+ language_id: number
26
+ language_iso: string
27
+ progress: number
28
+ words_to_do: number
29
+ project_language_id: number
30
+ project_language_uuid: string
31
+ }
32
+
33
+ export type GetProjectLanguagesResponse = {
34
+ projectId: string
35
+ workspaceId: string
36
+ baseLanguageId: string
37
+ languages: ProjectLanguage[]
38
+ }
39
+
40
+ type ProjectLanguage = {
41
+ id: string
42
+ locale: string
43
+ isBaseLanguage: boolean
44
+ }
45
+
46
+ export type UserResponse = {
47
+ id: number
48
+ uuid: string
49
+ email: string
50
+ fullname: string
51
+ }
52
+
53
+ /**
54
+ * Server-to-server client interface for interacting with the Expert API.
55
+ *
56
+ * This interface abstracts the internal implementation details of API access,
57
+ * which may change over time. It serves as a temporary solution and is intended
58
+ * to be replaced by the official public Node.js SDK in the future.
59
+ */
60
+ export interface ExpertApiS2SClient {
61
+ getProject(
62
+ projectId: string,
63
+ requestContext?: RequestContext,
64
+ ): Promise<GetProjectResponseExtendedVariant | null>
65
+
66
+ getTeamProjects(
67
+ teamId: string,
68
+ requestContext?: RequestContext,
69
+ ): AsyncGenerator<GetProjectResponseExtendedVariant>
70
+
71
+ getProjectLanguages(
72
+ projectId: string,
73
+ requestContext?: RequestContext,
74
+ ): Promise<GetProjectLanguagesResponse>
75
+
76
+ getUser(userId: string, requestContext?: RequestContext): Promise<UserResponse>
77
+ }
@@ -0,0 +1,445 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { constants as httpConstants } from 'node:http2'
3
+ import { ResponseStatusError } from '@lokalise/backend-http-client'
4
+ import type { GetExpertUserBasicData } from '@lokalise/workspaces-api-schemas'
5
+ import { getLocal } from 'mockttp'
6
+ import { describe } from 'vitest'
7
+ import { ZodError } from 'zod/v4'
8
+ import EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE from '../test/fixtures/expertOauth2TokenResponse.json' with {
9
+ type: 'json',
10
+ }
11
+ import EXPERT_OAUTH2_TOKEN_RESPONSE_WITHOUT_EXPIRY_SAMPLE from '../test/fixtures/expertOauth2TokenResponseWithoutExpiry.json' with {
12
+ type: 'json',
13
+ }
14
+ import EXPERT_REST_API_GET_BASIC_USER_DATA_RESPONSE_SAMPLE from '../test/fixtures/expertPublicApiGetBasicUserData.json' with {
15
+ type: 'json',
16
+ }
17
+ import EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE from '../test/fixtures/expertPublicApiGetProjectResponse.json' with {
18
+ type: 'json',
19
+ }
20
+ import EXPERT_REST_API_GET_PROJECTS_RESPONSE_SAMPLE_1 from '../test/fixtures/expertPublicApiGetProjectsResponse_MultiPage_1.json' with {
21
+ type: 'json',
22
+ }
23
+ import EXPERT_REST_API_GET_PROJECTS_RESPONSE_SAMPLE_2 from '../test/fixtures/expertPublicApiGetProjectsResponse_MultiPage_2.json' with {
24
+ type: 'json',
25
+ }
26
+ import { FAKE_REQUEST_CONTEXT } from '../test/requestContextMocks.ts'
27
+ import type { GetProjectLanguagesResponse } from './ExpertApiS2SClient.ts'
28
+ import { ExpertPublicApiClient } from './ExpertPublicApiClient.ts'
29
+
30
+ const FAKE_OAUTH2_CLIENT_ID = 'dummy-client-id'
31
+ const FAKE_OAUTH2_CLIENT_SECRET = 'dummy-client-secret'
32
+
33
+ describe('ExpertPublicApiClient', () => {
34
+ const mockttp = getLocal()
35
+ let client: ExpertPublicApiClient
36
+
37
+ beforeEach(async () => {
38
+ await mockttp.start()
39
+ client = new ExpertPublicApiClient(mockttp.url, {
40
+ serverUrl: mockttp.url,
41
+ clientId: FAKE_OAUTH2_CLIENT_ID,
42
+ clientSecret: FAKE_OAUTH2_CLIENT_SECRET,
43
+ allowInsecureRequests: true,
44
+ })
45
+ })
46
+
47
+ afterEach(async () => {
48
+ await mockttp.stop()
49
+ vi.useRealTimers()
50
+ })
51
+
52
+ describe('getProject', () => {
53
+ it('obtains a token, performs request with it and returns data when API responds with valid response', async () => {
54
+ // Given
55
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
56
+ const accessToken = EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE.access_token
57
+
58
+ await mockttp
59
+ .forPost('/oauth2/token')
60
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
61
+
62
+ await mockttp
63
+ .forGet(`/api2/projects/${projectUuid}`)
64
+ .withHeaders({
65
+ Authorization: `Bearer ${accessToken}`,
66
+ })
67
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE)
68
+
69
+ // When
70
+ const result = await client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)
71
+
72
+ // Then
73
+ expect(result).toEqual(EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE)
74
+ })
75
+ it('reuses the token for the next request if it is still valid', async () => {
76
+ // Given
77
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
78
+
79
+ const tokenEndpointMock = await mockttp
80
+ .forPost('/oauth2/token')
81
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
82
+
83
+ await mockttp
84
+ .forGet(`/api2/projects/${projectUuid}`)
85
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE)
86
+
87
+ // When
88
+ // We are requesting API subsequently.
89
+ await client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)
90
+ await client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)
91
+
92
+ // Then
93
+ // We should see there was only one request to the token endpoint.
94
+ await expect(tokenEndpointMock.getSeenRequests()).resolves.toHaveLength(1)
95
+ })
96
+ it('requests for a new token when previous one expired', async () => {
97
+ // Given
98
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
99
+
100
+ const tokenEndpointMock = await mockttp
101
+ .forPost('/oauth2/token')
102
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
103
+
104
+ await mockttp
105
+ .forGet(`/api2/projects/${projectUuid}`)
106
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE)
107
+
108
+ // When
109
+ await client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)
110
+
111
+ // Fast-forward time to simulate token expiration.
112
+ vi.setSystemTime(Date.now() + (3600 + 1) * 1000) // 1 second after expiration
113
+
114
+ // Request again, should trigger new token request.
115
+ await client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)
116
+
117
+ // Then
118
+ // Should see two requests to the token endpoint.
119
+ await expect(tokenEndpointMock.getSeenRequests()).resolves.toHaveLength(2)
120
+ })
121
+ it('supports tokens without expiry date', async () => {
122
+ // Given
123
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
124
+
125
+ const tokenEndpointMock = await mockttp
126
+ .forPost('/oauth2/token')
127
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_WITHOUT_EXPIRY_SAMPLE)
128
+
129
+ await mockttp
130
+ .forGet(`/api2/projects/${projectUuid}`)
131
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE)
132
+
133
+ // When
134
+ await client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)
135
+
136
+ // Fast-forward time to simulate token expiration.
137
+ vi.setSystemTime(Date.now() + (3600 + 1) * 1000) // 1 second after expiration
138
+
139
+ // Request again, still should not trigger new token request.
140
+ await client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)
141
+
142
+ // Then
143
+ // We should see there was only one request to the token endpoint.
144
+ await expect(tokenEndpointMock.getSeenRequests()).resolves.toHaveLength(1)
145
+ })
146
+
147
+ it('throws an error if project response if not passing validation', async () => {
148
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
149
+
150
+ await mockttp
151
+ .forPost('/oauth2/token')
152
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_WITHOUT_EXPIRY_SAMPLE)
153
+
154
+ await mockttp
155
+ .forGet(`/api2/projects/${projectUuid}`)
156
+ .thenJson(httpConstants.HTTP_STATUS_OK, { data: 'nonsense' })
157
+
158
+ await expect(client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)).rejects.toThrow(ZodError)
159
+ })
160
+
161
+ it('throws an error if request fails', async () => {
162
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
163
+
164
+ await mockttp
165
+ .forPost('/oauth2/token')
166
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_WITHOUT_EXPIRY_SAMPLE)
167
+
168
+ await mockttp
169
+ .forGet(`/api2/projects/${projectUuid}`)
170
+ .thenJson(httpConstants.HTTP_STATUS_UNAUTHORIZED, { error: 'Access denied' })
171
+
172
+ await expect(client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)).rejects.toThrow(
173
+ ResponseStatusError,
174
+ )
175
+ })
176
+
177
+ it('returns null if project is not found', async () => {
178
+ // Given
179
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
180
+
181
+ await mockttp
182
+ .forPost('/oauth2/token')
183
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_WITHOUT_EXPIRY_SAMPLE)
184
+
185
+ await mockttp
186
+ .forGet(`/api2/projects/${projectUuid}`)
187
+ .thenJson(httpConstants.HTTP_STATUS_NOT_FOUND, { error: 'Project not found' })
188
+
189
+ // When
190
+ const result = await client.getProject(projectUuid, FAKE_REQUEST_CONTEXT)
191
+
192
+ // Then
193
+ expect(result).toEqual(null)
194
+ })
195
+ })
196
+
197
+ describe('getTeamProjects', () => {
198
+ it('obtains a token, loops through the multiple pages or results until data is collected', async () => {
199
+ // Given
200
+ const teamUuid = randomUUID()
201
+ const accessToken = EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE.access_token
202
+
203
+ await mockttp
204
+ .forPost('/oauth2/token')
205
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
206
+
207
+ // First page response
208
+ await mockttp
209
+ .forGet('/api2/projects')
210
+ .withQuery({
211
+ filter_team_id: teamUuid,
212
+ page: 1,
213
+ })
214
+ .withHeaders({
215
+ Authorization: `Bearer ${accessToken}`,
216
+ })
217
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_PROJECTS_RESPONSE_SAMPLE_1, {
218
+ 'x-pagination-total-count': '2',
219
+ 'x-pagination-page-count': '2',
220
+ 'x-pagination-limit': '1',
221
+ 'x-pagination-page': '1',
222
+ })
223
+
224
+ // Second page response
225
+ await mockttp
226
+ .forGet('/api2/projects')
227
+ .withQuery({
228
+ filter_team_id: teamUuid,
229
+ page: 2,
230
+ })
231
+ .withHeaders({
232
+ Authorization: `Bearer ${accessToken}`,
233
+ })
234
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_PROJECTS_RESPONSE_SAMPLE_2, {
235
+ 'x-pagination-total-count': '2',
236
+ 'x-pagination-page-count': '2',
237
+ 'x-pagination-limit': '1',
238
+ 'x-pagination-page': '2',
239
+ })
240
+
241
+ // When
242
+ const result = []
243
+ for await (const project of client.getTeamProjects(teamUuid, FAKE_REQUEST_CONTEXT)) {
244
+ result.push(project)
245
+ }
246
+
247
+ // Then
248
+ expect(result).toEqual([
249
+ EXPERT_REST_API_GET_PROJECTS_RESPONSE_SAMPLE_1.projects[0],
250
+ EXPERT_REST_API_GET_PROJECTS_RESPONSE_SAMPLE_2.projects[0],
251
+ ])
252
+ })
253
+ it('stops looping in case of a wrong response', async () => {
254
+ const teamUuid = randomUUID()
255
+ const accessToken = EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE.access_token
256
+
257
+ await mockttp
258
+ .forPost('/oauth2/token')
259
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
260
+
261
+ // First page response
262
+ await mockttp
263
+ .forGet('/api2/projects')
264
+ .withQuery({
265
+ filter_team_id: teamUuid,
266
+ page: 1,
267
+ })
268
+ .withHeaders({
269
+ Authorization: `Bearer ${accessToken}`,
270
+ })
271
+ .thenJson(
272
+ httpConstants.HTTP_STATUS_OK,
273
+ { data: 'nonsense' },
274
+ {
275
+ 'x-pagination-total-count': '2',
276
+ 'x-pagination-page-count': '2',
277
+ 'x-pagination-limit': '1',
278
+ 'x-pagination-page': '1',
279
+ },
280
+ )
281
+
282
+ const result = []
283
+ for await (const project of client.getTeamProjects(teamUuid, FAKE_REQUEST_CONTEXT)) {
284
+ result.push(project)
285
+ }
286
+
287
+ expect(result).toEqual([])
288
+ })
289
+ it('stops fetching projects when maxPages is reached', async () => {
290
+ // Given
291
+ const teamUuid = randomUUID()
292
+ const accessToken = EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE.access_token
293
+
294
+ await mockttp
295
+ .forPost('/oauth2/token')
296
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
297
+
298
+ await mockttp
299
+ .forGet('/api2/projects')
300
+ .withQuery({
301
+ filter_team_id: teamUuid,
302
+ page: 1,
303
+ })
304
+ .withHeaders({
305
+ Authorization: `Bearer ${accessToken}`,
306
+ })
307
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_PROJECTS_RESPONSE_SAMPLE_1, {
308
+ 'x-pagination-total-count': '100500',
309
+ 'x-pagination-page-count': '1000',
310
+ 'x-pagination-limit': '100',
311
+ 'x-pagination-page': '1',
312
+ })
313
+
314
+ const maxPages = 1
315
+ const loggerSpy = vi.spyOn(FAKE_REQUEST_CONTEXT.logger, 'warn')
316
+
317
+ const result = []
318
+ for await (const project of client.getTeamProjects(
319
+ teamUuid,
320
+ FAKE_REQUEST_CONTEXT,
321
+ maxPages,
322
+ )) {
323
+ result.push(project)
324
+ }
325
+
326
+ expect(result).toEqual([EXPERT_REST_API_GET_PROJECTS_RESPONSE_SAMPLE_1.projects[0]])
327
+
328
+ expect(loggerSpy).toHaveBeenCalledWith(
329
+ expect.objectContaining({
330
+ teamId: teamUuid,
331
+ maxPages,
332
+ totalPages: 1000,
333
+ }),
334
+ expect.stringContaining('Reached maximum number of pages while fetching projects for team'),
335
+ )
336
+ })
337
+ })
338
+
339
+ describe('getProjectLanguages', () => {
340
+ it('obtains a token, performs request with it and returns mapped languages extracted from the response', async () => {
341
+ // Given
342
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
343
+ const accessToken = EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE.access_token
344
+
345
+ await mockttp
346
+ .forPost('/oauth2/token')
347
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
348
+
349
+ await mockttp
350
+ .forGet(`/api2/projects/${projectUuid}`)
351
+ .withHeaders({
352
+ Authorization: `Bearer ${accessToken}`,
353
+ })
354
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE)
355
+
356
+ // When
357
+ const result = await client.getProjectLanguages(projectUuid, FAKE_REQUEST_CONTEXT)
358
+
359
+ // Then
360
+ expect(result).toEqual({
361
+ projectId: projectUuid,
362
+ workspaceId: '1cc09f87-b8d7-425c-97e1-16e42d8644ed',
363
+ baseLanguageId: '0195aaf1-ef28-7359-be22-8b02918b6b7d',
364
+ languages: [
365
+ {
366
+ id: '0195aaf1-ef28-7359-be22-8b02918b6b7d',
367
+ locale: 'en',
368
+ isBaseLanguage: true,
369
+ },
370
+ {
371
+ id: '0195aaf1-ef28-7359-be22-8b02921d367e',
372
+ locale: 'lv',
373
+ isBaseLanguage: false,
374
+ },
375
+ ],
376
+ } satisfies GetProjectLanguagesResponse)
377
+ })
378
+
379
+ it('throws an error if project is not found', async () => {
380
+ // Given
381
+ const projectUuid = EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE.uuid
382
+
383
+ await mockttp
384
+ .forPost('/oauth2/token')
385
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
386
+
387
+ await mockttp
388
+ .forGet(`/api2/projects/${projectUuid}`)
389
+ .thenJson(httpConstants.HTTP_STATUS_NOT_FOUND, { error: 'Project not found' })
390
+
391
+ // When + Then
392
+ await expect(client.getProjectLanguages(projectUuid, FAKE_REQUEST_CONTEXT)).rejects.toThrow(
393
+ new Error(`Project not found: ${projectUuid}`),
394
+ )
395
+ })
396
+ })
397
+
398
+ describe('getUser', () => {
399
+ it('obtains a token, performs request with it and returns user basic info from the response', async () => {
400
+ // Given
401
+ const userId = EXPERT_REST_API_GET_BASIC_USER_DATA_RESPONSE_SAMPLE.id
402
+ const accessToken = EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE.access_token
403
+
404
+ await mockttp
405
+ .forPost('/oauth2/token')
406
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
407
+
408
+ await mockttp
409
+ .forGet(`/api2/users/${userId}`)
410
+ .withHeaders({
411
+ Authorization: `Bearer ${accessToken}`,
412
+ })
413
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_REST_API_GET_BASIC_USER_DATA_RESPONSE_SAMPLE)
414
+
415
+ // When
416
+ const result = await client.getUser(`${userId}`, FAKE_REQUEST_CONTEXT)
417
+
418
+ // Then
419
+ expect(result).toEqual({
420
+ id: userId,
421
+ uuid: '01982dac-13dc-7a72-9e42-f43f67c5342d',
422
+ email: 'test@lokalise.com',
423
+ fullname: 'Test User',
424
+ } satisfies GetExpertUserBasicData)
425
+ })
426
+
427
+ it('throws an error if project is not found', async () => {
428
+ // Given
429
+ const userId = EXPERT_REST_API_GET_BASIC_USER_DATA_RESPONSE_SAMPLE.id
430
+
431
+ await mockttp
432
+ .forPost('/oauth2/token')
433
+ .thenJson(httpConstants.HTTP_STATUS_OK, EXPERT_OAUTH2_TOKEN_RESPONSE_SAMPLE)
434
+
435
+ await mockttp
436
+ .forGet(`/api2/users/${userId}`)
437
+ .thenJson(httpConstants.HTTP_STATUS_NOT_FOUND, { error: 'Project not found' })
438
+
439
+ // When + Then
440
+ await expect(client.getUser(`${userId}`, FAKE_REQUEST_CONTEXT)).rejects.toThrow(
441
+ new Error(`User not found: ${userId}`),
442
+ )
443
+ })
444
+ })
445
+ })
@@ -0,0 +1,207 @@
1
+ import { BackendExpertHttpClient } from '@lokalise/backend-expert-http-client'
2
+ import type { RequestContext } from '@lokalise/fastify-extras'
3
+ import type { GetExpertProjectData } from '@lokalise/workspaces-api-schemas'
4
+ import { allowInsecureRequests, Configuration, clientCredentialsGrant } from 'openid-client'
5
+ import type {
6
+ ExpertApiS2SClient,
7
+ GetProjectLanguagesResponse,
8
+ GetProjectResponseExtendedVariant,
9
+ UserResponse,
10
+ } from './ExpertApiS2SClient.ts'
11
+
12
+ type OAuth2ClientConfiguration = {
13
+ serverUrl: string
14
+ clientId: string
15
+ clientSecret: string
16
+ allowInsecureRequests?: boolean
17
+ }
18
+
19
+ const PROJECT_LIST_MAX_PAGES = 10
20
+
21
+ /**
22
+ * Public API based S2S client implementation that is aware of OAuth Client credentials authentication.
23
+ * Uses Node.js SDK for public API part and OpenID client for authentication part.
24
+ */
25
+ export class ExpertPublicApiClient implements ExpertApiS2SClient {
26
+ private readonly oauth2ClientConfiguration: OAuth2ClientConfiguration
27
+
28
+ /* OpenID client configuration is static and can be safely re-used, we want to instantiate it only once and will keep here for re-using. */
29
+ private openIdClientConfiguration?: Configuration
30
+
31
+ private accessToken: string | null
32
+
33
+ /* Lokalise API client depends on the access token so we need to re-create it each time we obtain a new token. */
34
+ private lokaliseApiClient: BackendExpertHttpClient
35
+
36
+ /* Access token we obtain is valid for a limited time, we need to keep track of it and re-obtain when it expires. */
37
+ private lastObtainedApiTokenExpiresAt?: Date
38
+
39
+ constructor(apiBaseUrl: string, oauth2ClientConfiguration: OAuth2ClientConfiguration) {
40
+ this.lokaliseApiClient = new BackendExpertHttpClient(apiBaseUrl)
41
+ this.accessToken = null
42
+ this.oauth2ClientConfiguration = oauth2ClientConfiguration
43
+ }
44
+
45
+ async getProject(
46
+ projectId: string,
47
+ requestContext?: RequestContext,
48
+ ): Promise<GetProjectResponseExtendedVariant | null> {
49
+ await this.refreshAuthenticationToken(requestContext)
50
+ const project = await this.lokaliseApiClient.getProject(
51
+ projectId,
52
+ this.accessToken,
53
+ requestContext,
54
+ )
55
+
56
+ if (!project) {
57
+ return null
58
+ }
59
+
60
+ return ExpertPublicApiClient.assertProjectResponseExtendedVariant(project)
61
+ }
62
+
63
+ async *getTeamProjects(
64
+ teamId: string,
65
+ requestContext?: RequestContext,
66
+ maxPages = PROJECT_LIST_MAX_PAGES,
67
+ ): AsyncGenerator<GetProjectResponseExtendedVariant> {
68
+ await this.refreshAuthenticationToken(requestContext)
69
+
70
+ let page = 1
71
+ let hasNextPage = true
72
+
73
+ while (hasNextPage) {
74
+ const projectsList = await this.lokaliseApiClient.getTeamProjects(
75
+ teamId,
76
+ page,
77
+ this.accessToken,
78
+ requestContext,
79
+ )
80
+ if (projectsList == null) {
81
+ break
82
+ }
83
+ page++
84
+ hasNextPage = projectsList.hasNextPage()
85
+
86
+ for (const project of projectsList.items) {
87
+ yield ExpertPublicApiClient.assertProjectResponseExtendedVariant(project)
88
+ }
89
+
90
+ // The check below is primarily to ensure we will never go into an infinite loop because of some bug in the SDK/API.
91
+ if (page > maxPages) {
92
+ requestContext?.logger.warn(
93
+ {
94
+ teamId,
95
+ maxPages,
96
+ totalPages: projectsList.totalPages,
97
+ },
98
+ 'Reached maximum number of pages while fetching projects for team, stopping the loop',
99
+ )
100
+ break
101
+ }
102
+ }
103
+ }
104
+
105
+ private async refreshAuthenticationToken(requestContext?: RequestContext): Promise<void> {
106
+ if (
107
+ this.accessToken === null ||
108
+ (this.lastObtainedApiTokenExpiresAt !== undefined &&
109
+ this.lastObtainedApiTokenExpiresAt < new Date())
110
+ ) {
111
+ const { accessToken, expiresAt } = await this.obtainNewAccessToken(requestContext)
112
+ this.accessToken = accessToken
113
+ this.lastObtainedApiTokenExpiresAt = expiresAt
114
+ }
115
+ }
116
+
117
+ async getProjectLanguages(
118
+ projectId: string,
119
+ requestContext?: RequestContext,
120
+ ): Promise<GetProjectLanguagesResponse> {
121
+ const project = await this.getProject(projectId, requestContext)
122
+
123
+ if (!project) {
124
+ throw new Error(`Project not found: ${projectId}`)
125
+ }
126
+
127
+ const baseLanguageId = project.base_project_language_uuid
128
+ return {
129
+ // biome-ignore lint/style/noNonNullAssertion: TODO: uuid should be always defined here, but is marked as optional in type
130
+ projectId: project.uuid!,
131
+ workspaceId: project.team_uuid,
132
+ baseLanguageId: project.base_project_language_uuid,
133
+ languages: project.statistics.languages.map((l) => ({
134
+ id: l.project_language_uuid,
135
+ locale: l.language_iso.replace('_', '-'),
136
+ isBaseLanguage: l.project_language_uuid === baseLanguageId,
137
+ })),
138
+ }
139
+ }
140
+
141
+ async getUser(userId: string, requestContext?: RequestContext): Promise<UserResponse> {
142
+ await this.refreshAuthenticationToken(requestContext)
143
+ const user = await this.lokaliseApiClient.getBasicUserData(
144
+ userId,
145
+ this.accessToken,
146
+ requestContext,
147
+ )
148
+
149
+ if (!user) {
150
+ throw new Error(`User not found: ${userId}`)
151
+ }
152
+
153
+ return {
154
+ id: user.id,
155
+ uuid: user.uuid,
156
+ email: user.email,
157
+ fullname: user.fullname,
158
+ }
159
+ }
160
+
161
+ private async obtainNewAccessToken(requestContext?: RequestContext) {
162
+ const config = this.getOrCreateOpenIdClientConfiguration(requestContext)
163
+
164
+ const { access_token: accessToken, expires_in: expiresIn } = await clientCredentialsGrant(
165
+ config,
166
+ { scope: 'service_to_service' },
167
+ )
168
+ return {
169
+ accessToken,
170
+ expiresAt: expiresIn ? new Date(Date.now() + expiresIn * 1000) : undefined,
171
+ }
172
+ }
173
+
174
+ private getOrCreateOpenIdClientConfiguration(requestContext?: RequestContext) {
175
+ if (!this.openIdClientConfiguration) {
176
+ const config = new Configuration(
177
+ {
178
+ issuer: 'lokalise-expert', //
179
+ token_endpoint: `${this.oauth2ClientConfiguration.serverUrl}/oauth2/token`,
180
+ },
181
+ this.oauth2ClientConfiguration.clientId,
182
+ this.oauth2ClientConfiguration.clientSecret,
183
+ )
184
+
185
+ if (this.oauth2ClientConfiguration.allowInsecureRequests) {
186
+ requestContext?.logger.warn(
187
+ 'DEVELOPMENT MODE! Insecure requests are allowed for Expert OAuth2 client',
188
+ )
189
+ allowInsecureRequests(config)
190
+ }
191
+
192
+ this.openIdClientConfiguration = config
193
+ }
194
+
195
+ return this.openIdClientConfiguration
196
+ }
197
+
198
+ private static assertProjectResponseExtendedVariant(
199
+ project: GetExpertProjectData,
200
+ ): GetProjectResponseExtendedVariant {
201
+ /**
202
+ * TODO: actual response is compatible with GetProjectResponseExtendedVariant, but the type is not
203
+ * Remove once addressed in the Node SDK
204
+ */
205
+ return project as GetProjectResponseExtendedVariant
206
+ }
207
+ }
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE from '../test/fixtures/expertPublicApiGetProjectResponse.json' with {
3
+ type: 'json',
4
+ }
5
+ import { mapExpertGetProjectResponseToAutopilotProject } from './autopilotProjectUtils.ts'
6
+
7
+ describe('mapExpertGetProjectResponseToAutopilotProject', () => {
8
+ it('maps a valid expert project response to Autopilot project', () => {
9
+ const autopilotProject = mapExpertGetProjectResponseToAutopilotProject(
10
+ EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE,
11
+ )
12
+
13
+ expect(autopilotProject).toEqual({
14
+ baseContentLanguageId: '0195aaf1-ef28-7359-be22-8b02918b6b7d',
15
+ createdAt: '2025-03-18T20:28:57.000Z',
16
+ deletedAt: null,
17
+ description: '',
18
+ externalId: '8803108167d9d789431338.69025157',
19
+ id: '0195aaf1-f03a-701d-b5d4-28fbd011baae',
20
+ name: 'Sample Project',
21
+ reviewType: 'REVIEW_UNCONFIDENT',
22
+ status: 'ACTIVE',
23
+ updatedAt: null,
24
+ workspaceId: '1cc09f87-b8d7-425c-97e1-16e42d8644ed',
25
+ })
26
+ })
27
+ it('throws an error if the expert project is missing UUID', () => {
28
+ expect(() =>
29
+ mapExpertGetProjectResponseToAutopilotProject({
30
+ ...EXPERT_REST_API_GET_PROJECT_RESPONSE_SAMPLE,
31
+ uuid: null,
32
+ }),
33
+ ).toThrow(
34
+ 'Expert project 8803108167d9d789431338.69025157 is missing UUID and cannot be mapped to an Autopilot project',
35
+ )
36
+ })
37
+ })
@@ -0,0 +1,34 @@
1
+ import { ReviewTypeEnum } from '@lokalise/common-api-schemas'
2
+ import type { Project as AutopilotProject } from '@lokalise/workspaces-api-schemas'
3
+ import { ProjectStatusEnum } from '@lokalise/workspaces-api-schemas'
4
+ import type { GetProjectResponseExtendedVariant } from './ExpertApiS2SClient.ts'
5
+
6
+ /**
7
+ * Although this is something that is not directly used by clients from this package, this mapping is likely to be needed in multiple services.
8
+ * It is therefore placed here to avoid code duplication.
9
+ */
10
+ export function mapExpertGetProjectResponseToAutopilotProject(
11
+ expertResponse: GetProjectResponseExtendedVariant,
12
+ ): AutopilotProject {
13
+ // Although UUID is optional in Expert API responses, Autopilot requires it.
14
+ // It is expected to be present for newly created projects,therefore should be safe enforcing this explicitly here.
15
+ if (!expertResponse.uuid) {
16
+ throw new Error(
17
+ `Expert project ${expertResponse.project_id} is missing UUID and cannot be mapped to an Autopilot project`,
18
+ )
19
+ }
20
+
21
+ return {
22
+ id: expertResponse.uuid,
23
+ externalId: expertResponse.project_id,
24
+ workspaceId: expertResponse.team_uuid,
25
+ description: expertResponse.description,
26
+ name: expertResponse.name,
27
+ baseContentLanguageId: expertResponse.base_project_language_uuid,
28
+ status: ProjectStatusEnum.ACTIVE, // Expert projects lack explicit status. Defaulting to ACTIVE. (FIXME: reference to a task to address it properly)
29
+ reviewType: ReviewTypeEnum.REVIEW_UNCONFIDENT, // Expert projects lack review type, using a hardcoded one. TODO: to be addressed in https://lokalise.atlassian.net/browse/EXP-254
30
+ createdAt: new Date(expertResponse.created_at_timestamp * 1000).toISOString(),
31
+ updatedAt: null, // Expert API doesn't provide a last updated timestamp.
32
+ deletedAt: null, // No deleted projects are visible through API, null is appropriate. (TODO: Consider whether deletedAt should be internal repository detail, discuss hiding the value with relevant stakeholders (FIXME: task reference))
33
+ }
34
+ }
@@ -0,0 +1,31 @@
1
+ import { buildClient } from './clientFactory.ts'
2
+ import { ExpertPublicApiClient } from './ExpertPublicApiClient.ts'
3
+
4
+ describe('clientFactory', () => {
5
+ describe('buildClient', () => {
6
+ it('creates a public client', () => {
7
+ const client = buildClient({
8
+ baseUrl: 'https://api.example.org',
9
+ oauth2: {
10
+ serverUrl: 'https://oauth.example.com',
11
+ clientId: 'client-id',
12
+ clientSecret: 'client-secret',
13
+ },
14
+ })
15
+
16
+ expect(client).toBeInstanceOf(ExpertPublicApiClient)
17
+ })
18
+ it('creates a public client without internal API configuration', () => {
19
+ const client = buildClient({
20
+ baseUrl: 'https://api.example.org',
21
+ oauth2: {
22
+ serverUrl: 'https://oauth.example.com',
23
+ clientId: 'client-id',
24
+ clientSecret: 'client-secret',
25
+ },
26
+ })
27
+
28
+ expect(client).toBeInstanceOf(ExpertPublicApiClient)
29
+ })
30
+ })
31
+ })
@@ -0,0 +1,21 @@
1
+ import type { ExpertApiS2SClient } from './ExpertApiS2SClient.ts'
2
+ import { ExpertPublicApiClient } from './ExpertPublicApiClient.ts'
3
+
4
+ export type ExpertApiS2SClientConfiguration = {
5
+ baseUrl: string
6
+ oauth2: {
7
+ serverUrl: string
8
+ clientId: string
9
+ clientSecret: string
10
+ allowInsecureRequests?: boolean
11
+ }
12
+ }
13
+
14
+ export function buildClient(config: ExpertApiS2SClientConfiguration): ExpertApiS2SClient {
15
+ return new ExpertPublicApiClient(config.baseUrl, {
16
+ serverUrl: config.oauth2.serverUrl,
17
+ clientId: config.oauth2.clientId,
18
+ clientSecret: config.oauth2.clientSecret,
19
+ allowInsecureRequests: config.oauth2.allowInsecureRequests,
20
+ })
21
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { mapExpertGetProjectResponseToAutopilotProject } from './autopilotProjectUtils.ts'
2
+ export { buildClient, type ExpertApiS2SClientConfiguration } from './clientFactory.ts'
3
+ export type {
4
+ ExpertApiS2SClient,
5
+ GetProjectLanguagesResponse,
6
+ GetProjectResponseExtendedVariant,
7
+ UserResponse,
8
+ } from './ExpertApiS2SClient.ts'
9
+ export { ExpertPublicApiClient } from './ExpertPublicApiClient.ts'