@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 +3 -0
- package/package.json +49 -0
- package/src/ExpertApiS2SClient.ts +77 -0
- package/src/ExpertPublicApiClient.spec.ts +445 -0
- package/src/ExpertPublicApiClient.ts +207 -0
- package/src/autopilotProjectUtils.spec.ts +37 -0
- package/src/autopilotProjectUtils.ts +34 -0
- package/src/clientFactory.spec.ts +31 -0
- package/src/clientFactory.ts +21 -0
- package/src/index.ts +9 -0
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'
|