@open-mercato/sync-akeneo 0.9.1-develop.7553.1.8dbab05b5a → 0.9.1-develop.7567.1.b5e80ff620
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/dist/modules/sync_akeneo/lib/catalog-importer.js +89 -40
- package/dist/modules/sync_akeneo/lib/catalog-importer.js.map +2 -2
- package/dist/modules/sync_akeneo/lib/client.js +143 -70
- package/dist/modules/sync_akeneo/lib/client.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/sync_akeneo/__tests__/catalog-importer.media-limits.test.ts +426 -0
- package/src/modules/sync_akeneo/__tests__/client.response-limits.test.ts +339 -0
- package/src/modules/sync_akeneo/i18n/de.json +1 -0
- package/src/modules/sync_akeneo/i18n/en.json +1 -0
- package/src/modules/sync_akeneo/i18n/es.json +1 -0
- package/src/modules/sync_akeneo/i18n/ko.json +1 -0
- package/src/modules/sync_akeneo/i18n/pl.json +1 -0
- package/src/modules/sync_akeneo/lib/catalog-importer.ts +97 -44
- package/src/modules/sync_akeneo/lib/client.ts +174 -98
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveAttachmentMaxBytes } from '@open-mercato/core/modules/attachments/lib/upload-limits'
|
|
1
2
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
2
3
|
import {
|
|
3
4
|
FetchTimeoutError,
|
|
@@ -18,6 +19,17 @@ type TokenState = {
|
|
|
18
19
|
expiresAt: number
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
type AkeneoTokenPayload = {
|
|
23
|
+
access_token?: string
|
|
24
|
+
refresh_token?: string
|
|
25
|
+
expires_in?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type AkeneoAttemptOutcome<T> =
|
|
29
|
+
| { kind: 'done'; value: T }
|
|
30
|
+
| { kind: 'reauthenticate' }
|
|
31
|
+
| { kind: 'rate_limited'; retryAfter: string | null }
|
|
32
|
+
|
|
21
33
|
type AkeneoListResponse<T> = {
|
|
22
34
|
_embedded?: {
|
|
23
35
|
items?: T[]
|
|
@@ -59,6 +71,20 @@ export class UnsafeAkeneoUrlError extends Error {
|
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
export class AkeneoResponseTooLargeError extends Error {
|
|
75
|
+
public readonly limitBytes: number
|
|
76
|
+
|
|
77
|
+
constructor(limitBytes: number) {
|
|
78
|
+
super(`Akeneo response exceeds the ${limitBytes} byte limit`)
|
|
79
|
+
this.name = 'AkeneoResponseTooLargeError'
|
|
80
|
+
this.limitBytes = limitBytes
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isAkeneoResponseTooLargeError(error: unknown): error is AkeneoResponseTooLargeError {
|
|
85
|
+
return error instanceof Error && error.name === 'AkeneoResponseTooLargeError'
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
type AkeneoClient = ReturnType<typeof createAkeneoClient>
|
|
63
89
|
|
|
64
90
|
const AKENEO_URL_SUBJECT = 'Akeneo URL'
|
|
@@ -250,6 +276,8 @@ function sleep(ms: number): Promise<void> {
|
|
|
250
276
|
const DEFAULT_AKENEO_REQUEST_TIMEOUT_MS = 30_000
|
|
251
277
|
const DEFAULT_AKENEO_MAX_RATE_LIMIT_RETRIES = 5
|
|
252
278
|
const DEFAULT_AKENEO_RETRY_AFTER_CAP_MS = 60_000
|
|
279
|
+
const DEFAULT_AKENEO_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
280
|
+
const AKENEO_ERROR_BODY_MAX_BYTES = 64 * 1024
|
|
253
281
|
|
|
254
282
|
function resolvePositiveIntEnv(raw: string | undefined, fallback: number): number {
|
|
255
283
|
const parsed = Number.parseInt(raw ?? '', 10)
|
|
@@ -271,11 +299,90 @@ function clampAkeneoRetryAfterMs(retryAfterHeader: string | null, env: NodeJS.Pr
|
|
|
271
299
|
return Math.min(Math.max(requestedMs, 0), cap)
|
|
272
300
|
}
|
|
273
301
|
|
|
274
|
-
|
|
302
|
+
function resolveAkeneoMaxResponseBytes(env: NodeJS.ProcessEnv = process.env): number {
|
|
303
|
+
return resolvePositiveIntEnv(env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES, DEFAULT_AKENEO_MAX_RESPONSE_BYTES)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function abortReasonError(signal: AbortSignal): Error {
|
|
307
|
+
return signal.reason instanceof Error ? signal.reason : new DOMException('Aborted', 'AbortError')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function settleBeforeAbort<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
311
|
+
if (signal.aborted) return Promise.reject(abortReasonError(signal))
|
|
312
|
+
return new Promise<T>((resolve, reject) => {
|
|
313
|
+
const onAbort = () => reject(abortReasonError(signal))
|
|
314
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
315
|
+
pending.then(
|
|
316
|
+
(value) => {
|
|
317
|
+
signal.removeEventListener('abort', onAbort)
|
|
318
|
+
resolve(value)
|
|
319
|
+
},
|
|
320
|
+
(error: unknown) => {
|
|
321
|
+
signal.removeEventListener('abort', onAbort)
|
|
322
|
+
reject(error)
|
|
323
|
+
},
|
|
324
|
+
)
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function discardAkeneoBody(response: Response): void {
|
|
329
|
+
void response.body?.cancel().catch(() => undefined)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function readAkeneoBody(
|
|
333
|
+
response: Response,
|
|
334
|
+
options: { maxBytes: number; signal: AbortSignal; truncate?: boolean },
|
|
335
|
+
): Promise<Buffer> {
|
|
336
|
+
const { maxBytes, signal, truncate = false } = options
|
|
337
|
+
const declaredLength = Number(response.headers.get('content-length') ?? '')
|
|
338
|
+
if (!truncate && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
339
|
+
discardAkeneoBody(response)
|
|
340
|
+
throw new AkeneoResponseTooLargeError(maxBytes)
|
|
341
|
+
}
|
|
342
|
+
if (!response.body) return Buffer.alloc(0)
|
|
343
|
+
|
|
344
|
+
const reader = response.body.getReader()
|
|
345
|
+
const chunks: Uint8Array[] = []
|
|
346
|
+
let receivedBytes = 0
|
|
347
|
+
let finished = false
|
|
348
|
+
try {
|
|
349
|
+
while (true) {
|
|
350
|
+
const { done, value } = await settleBeforeAbort(reader.read(), signal)
|
|
351
|
+
if (done) {
|
|
352
|
+
finished = true
|
|
353
|
+
break
|
|
354
|
+
}
|
|
355
|
+
if (receivedBytes + value.byteLength > maxBytes) {
|
|
356
|
+
if (!truncate) throw new AkeneoResponseTooLargeError(maxBytes)
|
|
357
|
+
chunks.push(value.subarray(0, maxBytes - receivedBytes))
|
|
358
|
+
receivedBytes = maxBytes
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
chunks.push(value)
|
|
362
|
+
receivedBytes += value.byteLength
|
|
363
|
+
}
|
|
364
|
+
} finally {
|
|
365
|
+
if (!finished) void reader.cancel().catch(() => undefined)
|
|
366
|
+
}
|
|
367
|
+
return Buffer.concat(chunks, receivedBytes)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function readAkeneoJson<T>(response: Response, signal: AbortSignal): Promise<T> {
|
|
371
|
+
const bytes = await readAkeneoBody(response, { maxBytes: resolveAkeneoMaxResponseBytes(), signal })
|
|
372
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as T
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readAkeneoErrorText(response: Response, signal: AbortSignal): Promise<string> {
|
|
376
|
+
const bytes = await readAkeneoBody(response, { maxBytes: AKENEO_ERROR_BODY_MAX_BYTES, signal, truncate: true })
|
|
377
|
+
return new TextDecoder().decode(bytes)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function withAkeneoResponse<T>(
|
|
275
381
|
input: string,
|
|
276
|
-
init: FetchWithTimeoutInit
|
|
277
|
-
deps: AkeneoClientDeps
|
|
278
|
-
|
|
382
|
+
init: FetchWithTimeoutInit,
|
|
383
|
+
deps: AkeneoClientDeps,
|
|
384
|
+
consume: (response: Response, signal: AbortSignal) => Promise<T>,
|
|
385
|
+
): Promise<T> {
|
|
279
386
|
const { timeoutMs, signal, ...rest } = init
|
|
280
387
|
const effectiveTimeout = resolveTimeoutMs(timeoutMs)
|
|
281
388
|
const controller = new AbortController()
|
|
@@ -296,13 +403,14 @@ async function safeAkeneoFetchWithTimeout(
|
|
|
296
403
|
}
|
|
297
404
|
|
|
298
405
|
try {
|
|
299
|
-
|
|
406
|
+
const response = await safeOutboundFetch(input, { ...rest, signal: controller.signal }, {
|
|
300
407
|
errorFactory: akeneoUrlErrorFactory,
|
|
301
408
|
subject: AKENEO_URL_SUBJECT,
|
|
302
409
|
allowPrivate: deps.allowPrivate ?? isAllowPrivateAkeneoUrlsEnabled(),
|
|
303
410
|
lookupHost: deps.lookupHost,
|
|
304
411
|
fetchImpl: deps.fetchImpl,
|
|
305
412
|
})
|
|
413
|
+
return await consume(response, controller.signal)
|
|
306
414
|
} catch (err) {
|
|
307
415
|
if ((err as { name?: string } | null)?.name === 'AbortError') {
|
|
308
416
|
const reason = (controller.signal as AbortSignal & { reason?: unknown }).reason
|
|
@@ -342,7 +450,7 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
|
|
|
342
450
|
}
|
|
343
451
|
|
|
344
452
|
async function acquirePasswordGrantToken(): Promise<TokenState> {
|
|
345
|
-
const
|
|
453
|
+
const payload = await withAkeneoResponse(tokenEndpointUrl, {
|
|
346
454
|
method: 'POST',
|
|
347
455
|
timeoutMs: resolveAkeneoRequestTimeoutMs(),
|
|
348
456
|
redirect: 'manual',
|
|
@@ -357,18 +465,13 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
|
|
|
357
465
|
username: credentials.username,
|
|
358
466
|
password: credentials.password,
|
|
359
467
|
}),
|
|
360
|
-
}, deps)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
const payload = await response.json() as {
|
|
368
|
-
access_token?: string
|
|
369
|
-
refresh_token?: string
|
|
370
|
-
expires_in?: number
|
|
371
|
-
}
|
|
468
|
+
}, deps, async (response, signal) => {
|
|
469
|
+
if (!response.ok) {
|
|
470
|
+
const message = await readAkeneoErrorText(response, signal)
|
|
471
|
+
throw new Error(`Akeneo authentication failed (${response.status}): ${message}`)
|
|
472
|
+
}
|
|
473
|
+
return readAkeneoJson<AkeneoTokenPayload>(response, signal)
|
|
474
|
+
})
|
|
372
475
|
if (!payload.access_token) {
|
|
373
476
|
throw new Error('Akeneo authentication did not return an access token')
|
|
374
477
|
}
|
|
@@ -382,7 +485,7 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
|
|
|
382
485
|
|
|
383
486
|
async function refreshAccessToken(current: TokenState): Promise<TokenState> {
|
|
384
487
|
if (!current.refreshToken) return acquirePasswordGrantToken()
|
|
385
|
-
const
|
|
488
|
+
const payload = await withAkeneoResponse(tokenEndpointUrl, {
|
|
386
489
|
method: 'POST',
|
|
387
490
|
timeoutMs: resolveAkeneoRequestTimeoutMs(),
|
|
388
491
|
redirect: 'manual',
|
|
@@ -396,18 +499,15 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
|
|
|
396
499
|
client_secret: credentials.clientSecret,
|
|
397
500
|
refresh_token: current.refreshToken,
|
|
398
501
|
}),
|
|
399
|
-
}, deps)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
502
|
+
}, deps, async (response, signal) => {
|
|
503
|
+
if (!response.ok) {
|
|
504
|
+
discardAkeneoBody(response)
|
|
505
|
+
return null
|
|
506
|
+
}
|
|
507
|
+
return readAkeneoJson<AkeneoTokenPayload>(response, signal)
|
|
508
|
+
})
|
|
404
509
|
|
|
405
|
-
|
|
406
|
-
access_token?: string
|
|
407
|
-
refresh_token?: string
|
|
408
|
-
expires_in?: number
|
|
409
|
-
}
|
|
410
|
-
if (!payload.access_token) return acquirePasswordGrantToken()
|
|
510
|
+
if (!payload?.access_token) return acquirePasswordGrantToken()
|
|
411
511
|
return {
|
|
412
512
|
accessToken: payload.access_token,
|
|
413
513
|
refreshToken: payload.refresh_token ?? current.refreshToken ?? null,
|
|
@@ -423,100 +523,76 @@ export function createAkeneoClient(credentialsInput: Record<string, unknown>, de
|
|
|
423
523
|
return tokenState.accessToken
|
|
424
524
|
}
|
|
425
525
|
|
|
426
|
-
async function
|
|
526
|
+
async function sendAuthenticated<T>(
|
|
427
527
|
pathOrUrl: string,
|
|
428
|
-
init: RequestInit
|
|
528
|
+
init: RequestInit,
|
|
529
|
+
defaultHeaders: Record<string, string>,
|
|
530
|
+
readSuccess: (response: Response, signal: AbortSignal) => Promise<T>,
|
|
429
531
|
retried = false,
|
|
430
532
|
rateLimitAttempts = 0,
|
|
431
533
|
): Promise<T> {
|
|
432
534
|
const url = resolveAkeneoRequestUrl(pathOrUrl)
|
|
433
535
|
const token = await ensureToken()
|
|
434
536
|
|
|
435
|
-
const
|
|
537
|
+
const outcome = await withAkeneoResponse<AkeneoAttemptOutcome<T>>(url, {
|
|
436
538
|
...init,
|
|
437
539
|
timeoutMs: resolveAkeneoRequestTimeoutMs(),
|
|
438
540
|
redirect: 'manual',
|
|
439
541
|
headers: {
|
|
440
|
-
|
|
542
|
+
...defaultHeaders,
|
|
441
543
|
authorization: `Bearer ${token}`,
|
|
442
544
|
...init.headers,
|
|
443
545
|
},
|
|
444
|
-
}, deps)
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
return request<T>(pathOrUrl, init, true, rateLimitAttempts)
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
if (response.status === 429) {
|
|
452
|
-
if (rateLimitAttempts >= resolveAkeneoMaxRateLimitRetries()) {
|
|
453
|
-
const body = await response.text()
|
|
454
|
-
throw new Error(`Akeneo request failed (429): ${body}`)
|
|
546
|
+
}, deps, async (response, signal) => {
|
|
547
|
+
if (response.status === 401 && !retried) {
|
|
548
|
+
discardAkeneoBody(response)
|
|
549
|
+
return { kind: 'reauthenticate' }
|
|
455
550
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
551
|
+
if (response.status === 429 && rateLimitAttempts < resolveAkeneoMaxRateLimitRetries()) {
|
|
552
|
+
discardAkeneoBody(response)
|
|
553
|
+
return { kind: 'rate_limited', retryAfter: response.headers.get('retry-after') }
|
|
554
|
+
}
|
|
555
|
+
if (!response.ok) {
|
|
556
|
+
const body = await readAkeneoErrorText(response, signal)
|
|
557
|
+
throw new Error(`Akeneo request failed (${response.status}): ${body}`)
|
|
558
|
+
}
|
|
559
|
+
return { kind: 'done', value: await readSuccess(response, signal) }
|
|
560
|
+
})
|
|
459
561
|
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
562
|
+
if (outcome.kind === 'reauthenticate') {
|
|
563
|
+
await ensureToken(true)
|
|
564
|
+
return sendAuthenticated(pathOrUrl, init, defaultHeaders, readSuccess, true, rateLimitAttempts)
|
|
463
565
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
return
|
|
566
|
+
if (outcome.kind === 'rate_limited') {
|
|
567
|
+
await sleep(clampAkeneoRetryAfterMs(outcome.retryAfter))
|
|
568
|
+
return sendAuthenticated(pathOrUrl, init, defaultHeaders, readSuccess, retried, rateLimitAttempts + 1)
|
|
467
569
|
}
|
|
570
|
+
return outcome.value
|
|
571
|
+
}
|
|
468
572
|
|
|
469
|
-
|
|
573
|
+
async function request<T>(pathOrUrl: string, init: RequestInit = {}): Promise<T> {
|
|
574
|
+
return sendAuthenticated<T>(pathOrUrl, init, { accept: 'application/json' }, async (response, signal) => {
|
|
575
|
+
if (response.status === 204) {
|
|
576
|
+
discardAkeneoBody(response)
|
|
577
|
+
return null as T
|
|
578
|
+
}
|
|
579
|
+
return readAkeneoJson<T>(response, signal)
|
|
580
|
+
})
|
|
470
581
|
}
|
|
471
582
|
|
|
472
|
-
async function requestBinary(
|
|
473
|
-
pathOrUrl: string,
|
|
474
|
-
init: RequestInit = {},
|
|
475
|
-
retried = false,
|
|
476
|
-
rateLimitAttempts = 0,
|
|
477
|
-
): Promise<{
|
|
583
|
+
async function requestBinary(pathOrUrl: string, init: RequestInit = {}): Promise<{
|
|
478
584
|
buffer: Buffer
|
|
479
585
|
contentType: string | null
|
|
480
586
|
contentLength: number | null
|
|
481
587
|
}> {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
redirect: 'manual',
|
|
489
|
-
headers: {
|
|
490
|
-
authorization: `Bearer ${token}`,
|
|
491
|
-
...init.headers,
|
|
492
|
-
},
|
|
493
|
-
}, deps)
|
|
494
|
-
|
|
495
|
-
if (response.status === 401 && !retried) {
|
|
496
|
-
await ensureToken(true)
|
|
497
|
-
return requestBinary(pathOrUrl, init, true, rateLimitAttempts)
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
if (response.status === 429) {
|
|
501
|
-
if (rateLimitAttempts >= resolveAkeneoMaxRateLimitRetries()) {
|
|
502
|
-
const body = await response.text()
|
|
503
|
-
throw new Error(`Akeneo request failed (429): ${body}`)
|
|
588
|
+
return sendAuthenticated(pathOrUrl, init, {}, async (response, signal) => {
|
|
589
|
+
const buffer = await readAkeneoBody(response, { maxBytes: resolveAttachmentMaxBytes(), signal })
|
|
590
|
+
return {
|
|
591
|
+
buffer,
|
|
592
|
+
contentType: response.headers.get('content-type'),
|
|
593
|
+
contentLength: buffer.byteLength,
|
|
504
594
|
}
|
|
505
|
-
|
|
506
|
-
return requestBinary(pathOrUrl, init, retried, rateLimitAttempts + 1)
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
if (!response.ok) {
|
|
510
|
-
const body = await response.text()
|
|
511
|
-
throw new Error(`Akeneo request failed (${response.status}): ${body}`)
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const arrayBuffer = await response.arrayBuffer()
|
|
515
|
-
return {
|
|
516
|
-
buffer: Buffer.from(arrayBuffer),
|
|
517
|
-
contentType: response.headers.get('content-type'),
|
|
518
|
-
contentLength: Number(response.headers.get('content-length') ?? '') || arrayBuffer.byteLength || null,
|
|
519
|
-
}
|
|
595
|
+
})
|
|
520
596
|
}
|
|
521
597
|
|
|
522
598
|
function buildUrl(path: string, params: Record<string, string | number | boolean | undefined | null>): string {
|