Skip to content

Commit 281aa6c

Browse files
committed
chore: Reduce dependency footprint
1 parent 1ab2232 commit 281aa6c

22 files changed

Lines changed: 285 additions & 136 deletions

File tree

packages/connectivity/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,9 @@
4444
"@sap-cloud-sdk/util": "workspace:^",
4545
"@sap/xsenv": "^6.2.0",
4646
"@sap/xssec": "^4.13.0",
47-
"async-retry": "^1.3.3",
4847
"axios": "^1.15.0",
4948
"jks-js": "^1.1.6",
50-
"jsonwebtoken": "^9.0.3",
49+
"jwt-decode": "^4.0.0",
5150
"safe-stable-stringify": "^2.5.0"
5251
},
5352
"devDependencies": {

packages/connectivity/src/scp-cf/destination/destination-service.spec.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ describe('destination service', () => {
355355

356356
describe('fetchDestinationByToken', () => {
357357
afterEach(() => {
358+
jest.useRealTimers();
358359
jest.restoreAllMocks();
359360
});
360361

@@ -597,6 +598,9 @@ describe('destination service', () => {
597598
});
598599

599600
it('does a retry if request fails with 500 error', async () => {
601+
jest.useFakeTimers();
602+
jest.spyOn(Math, 'random').mockReturnValue(0);
603+
600604
const response = {
601605
owner: {
602606
SubaccountId: 'a89ea924-d9c2-4eab-84fb-3ffcaadf5d24',
@@ -610,17 +614,46 @@ describe('destination service', () => {
610614
.get('/destination-configuration/v1/destinations/HTTP-BASIC')
611615
.reply(200, response);
612616

613-
const actual = await fetchDestinationWithTokenRetrieval(
617+
const request = fetchDestinationWithTokenRetrieval(
614618
destinationServiceUri,
615619
jwt,
616620
{
617621
destinationName: 'HTTP-BASIC',
618622
retry: true
619623
}
620624
);
625+
626+
await jest.advanceTimersByTimeAsync(1000);
627+
628+
const actual = await request;
621629
expect(actual).toEqual(parseDestination(response));
622630
});
623631

632+
it('stops retrying after the configured number of attempts for 500 errors', async () => {
633+
jest.useFakeTimers();
634+
jest.spyOn(Math, 'random').mockReturnValue(0);
635+
636+
const mock = nock(destinationServiceUri)
637+
.get('/destination-configuration/v1/destinations/HTTP-BASIC')
638+
.times(3)
639+
.reply(500);
640+
641+
const request = fetchDestinationWithTokenRetrieval(
642+
destinationServiceUri,
643+
jwt,
644+
{
645+
destinationName: 'HTTP-BASIC',
646+
retry: true
647+
}
648+
);
649+
650+
await jest.advanceTimersByTimeAsync(1000);
651+
await jest.advanceTimersByTimeAsync(2000);
652+
653+
await expect(request).rejects.toThrow();
654+
expect(mock.isDone()).toBe(true);
655+
});
656+
624657
it('does no retry if request fails with 401 error', async () => {
625658
const response = {
626659
owner: {
@@ -646,6 +679,9 @@ describe('destination service', () => {
646679
});
647680

648681
it('does a retry if auth token contains errors', async () => {
682+
jest.useFakeTimers();
683+
jest.spyOn(Math, 'random').mockReturnValue(0);
684+
649685
const responseErrorInToken = {
650686
owner: {
651687
SubaccountId: 'a89ea924-d9c2-4eab-84fb-3ffcaadf5d24',
@@ -679,18 +715,25 @@ describe('destination service', () => {
679715
.get('/destination-configuration/v1/destinations/HTTP-OAUTH')
680716
.reply(200, responseValidToken);
681717

682-
const actual = await fetchDestinationWithTokenRetrieval(
718+
const request = fetchDestinationWithTokenRetrieval(
683719
destinationServiceUri,
684720
jwt,
685721
{
686722
destinationName: 'HTTP-OAUTH',
687723
retry: true
688724
}
689725
);
726+
727+
await jest.advanceTimersByTimeAsync(1000);
728+
729+
const actual = await request;
690730
expect(actual).toMatchObject(parseDestination(responseValidToken));
691731
});
692732

693733
it('does a retry if auth tokens are failing but returns the destination with errors in the end', async () => {
734+
jest.useFakeTimers();
735+
jest.spyOn(Math, 'random').mockReturnValue(0);
736+
694737
const response = {
695738
owner: {
696739
SubaccountId: 'a89ea924-d9c2-4eab-84fb-3ffcaadf5d24',
@@ -713,17 +756,21 @@ describe('destination service', () => {
713756
.times(3)
714757
.reply(200, response);
715758

716-
const actual = await fetchDestinationWithTokenRetrieval(
759+
const request = fetchDestinationWithTokenRetrieval(
717760
destinationServiceUri,
718761
jwt,
719762
{
720763
destinationName: 'HTTP-OAUTH',
721764
retry: true
722765
}
723766
);
724-
expect(actual.authTokens![0].error).toEqual('ERROR');
767+
768+
await jest.advanceTimersByTimeAsync(1000);
769+
await jest.advanceTimersByTimeAsync(2000);
770+
771+
expect((await request).authTokens![0].error).toEqual('ERROR');
725772
expect(mock.isDone()).toBe(true);
726-
}, 10000);
773+
});
727774

728775
it('fetches a destination and returns 200 but authTokens are failing', async () => {
729776
const destinationName = 'FINAL-DESTINATION';

packages/connectivity/src/scp-cf/destination/destination-service.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
import axios from 'axios';
88
import { executeWithMiddleware } from '@sap-cloud-sdk/resilience/internal';
99
import { resilience } from '@sap-cloud-sdk/resilience';
10-
import asyncRetry from 'async-retry';
1110
import { decodeJwt, getTenantId, wrapJwtInHeader } from '../jwt';
1211
import { urlAndAgent } from '../../http-agent';
1312
import { buildAuthorizationHeaders } from '../authorization-header';
@@ -303,6 +302,55 @@ function errorMessageFromResponse(
303302
: '';
304303
}
305304

305+
/**
306+
* @internal
307+
* Retries a function with exponential backoff.
308+
* @param fn - The function to retry.
309+
* @param options - Options for retrying.
310+
* @param options.retries - The maximum number of retries. Default is 3.
311+
* @param options.onRetry - A callback function that is called before each retry with the error and the current attempt number.
312+
* @param options.randomize - Whether to randomize the backoff time by a factor of 1-2. Default is true.
313+
* @returns The result of the function.
314+
*/
315+
async function withRetry<T>(
316+
fn: (bail: (err: Error) => never, attempt: number) => Promise<T>,
317+
options: {
318+
retries?: number;
319+
onRetry?: (err: Error, attempt: number) => void;
320+
randomize?: boolean;
321+
} = {}
322+
): Promise<T> {
323+
const maxRetries = options.retries ?? 3;
324+
const randomize = Number(options.randomize ?? true);
325+
326+
class BailError extends Error {
327+
constructor(readonly cause: Error) {
328+
super(cause.message);
329+
}
330+
}
331+
332+
for (let attempt = 0; attempt < maxRetries - 1; attempt++) {
333+
try {
334+
return await fn(err => {
335+
throw new BailError(err);
336+
}, attempt);
337+
} catch (error) {
338+
if (error instanceof BailError) {
339+
throw error.cause;
340+
}
341+
options.onRetry?.(error as Error, attempt + 1);
342+
// Exponential backoff with optional randomization (factor 1-2x)
343+
const scalingFactor = 1 + randomize * Math.random();
344+
const backoff = Math.round(scalingFactor * 1000 * 2 ** attempt);
345+
await new Promise(resolve => setTimeout(resolve, backoff));
346+
}
347+
}
348+
349+
return fn(err => {
350+
throw new BailError(err);
351+
}, maxRetries - 1);
352+
}
353+
306354
function retryDestination(
307355
destinationName: string
308356
): Middleware<
@@ -311,20 +359,21 @@ function retryDestination(
311359
MiddlewareContext<RawAxiosRequestConfig>
312360
> {
313361
return options => arg => {
314-
let retryCount = 1;
315-
return asyncRetry(
316-
async bail => {
362+
const maxRetries = 3;
363+
return withRetry(
364+
async (bail, attempt) => {
317365
try {
318366
const destination = await options.fn(arg);
319-
if (retryCount < 3) {
320-
retryCount++;
367+
if (attempt < maxRetries - 1) {
321368
// this will throw if the destination does not contain valid auth headers and a second try is done to get a destination with valid tokens.
322369
await buildAuthorizationHeaders(parseDestination(destination.data));
323370
}
324371
return destination;
325372
} catch (error) {
326-
const status = error?.response?.status;
327-
if (status.toString().startsWith('4')) {
373+
const status = axios.isAxiosError(error)
374+
? error.response?.status
375+
: undefined;
376+
if (status?.toString().startsWith('4')) {
328377
bail(
329378
new ErrorWithCause(
330379
`Request failed with status code ${status}`,
@@ -338,7 +387,7 @@ function retryDestination(
338387
}
339388
},
340389
{
341-
retries: 3,
390+
retries: maxRetries,
342391
onRetry: (err: Error) =>
343392
logger.warn(
344393
`Failed to retrieve destination ${destinationName} - doing a retry. Original Error ${err.message}`

packages/connectivity/src/scp-cf/jwt/jwt.ts

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { createLogger, pickValueIgnoreCase } from '@sap-cloud-sdk/util';
2-
import { decode } from 'jsonwebtoken';
2+
import { jwtDecode } from 'jwt-decode';
33
import { Cache } from '../cache';
44
import { getIssuerSubdomain } from '../subdomain-replacer';
55
import type {
6-
Jwt,
6+
JwtHeader,
77
JwtPayload,
88
JwtWithPayloadObject
99
} from '../jsonwebtoken-type';
@@ -136,7 +136,7 @@ function audiencesFromAud({ aud }: JwtPayload): string[] {
136136
}
137137

138138
function audiencesFromScope({ scope }: JwtPayload): string[] {
139-
return makeArray(scope).reduce(
139+
return makeArray(scope).reduce<string[]>(
140140
(aud, s) => (s.includes('.') ? [...aud, s.split('.')[0]] : aud),
141141
[]
142142
);
@@ -148,7 +148,20 @@ function audiencesFromScope({ scope }: JwtPayload): string[] {
148148
* @returns Decoded payload.
149149
*/
150150
export function decodeJwt(token: string | JwtPayload): JwtPayload {
151-
return typeof token === 'string' ? decodeJwtComplete(token).payload : token;
151+
if (typeof token !== 'string') {
152+
return token;
153+
}
154+
try {
155+
validateJwtFormat(token);
156+
return decodeJwtPart<JwtPayload>(token);
157+
} catch (error) {
158+
throw new Error(
159+
'JwtError: The given jwt payload does not encode valid JSON.',
160+
{
161+
cause: error
162+
}
163+
);
164+
}
152165
}
153166

154167
/**
@@ -158,13 +171,21 @@ export function decodeJwt(token: string | JwtPayload): JwtPayload {
158171
* @internal
159172
*/
160173
export function decodeJwtComplete(token: string): JwtWithPayloadObject {
161-
const decodedToken = decode(token, { complete: true, json: true });
162-
if (decodedToken !== null && isJwtWithPayloadObject(decodedToken)) {
163-
return decodedToken;
174+
try {
175+
const signature = validateJwtFormat(token);
176+
return {
177+
header: decodeJwtPart<JwtHeader>(token, { header: true }),
178+
payload: decodeJwtPart<JwtPayload>(token),
179+
signature
180+
};
181+
} catch (error) {
182+
throw new Error(
183+
'JwtError: The given jwt payload does not encode valid JSON.',
184+
{
185+
cause: error
186+
}
187+
);
164188
}
165-
throw new Error(
166-
'JwtError: The given jwt payload does not encode valid JSON.'
167-
);
168189
}
169190

170191
/**
@@ -274,6 +295,41 @@ export function isUserToken(token: JwtPair | undefined): token is JwtPair {
274295
return !(keys.length === 1 && keys[0] === 'iss');
275296
}
276297

277-
function isJwtWithPayloadObject(decoded: Jwt): decoded is JwtWithPayloadObject {
278-
return typeof decoded.payload !== 'string';
298+
299+
/**
300+
* Validate the format of the given JWT and return the signature part if valid.
301+
* @returns The signature part of the JWT if the format is valid.
302+
* @throws An error if the JWT format is invalid.
303+
* @internal
304+
*/
305+
function validateJwtFormat(token: string): string {
306+
const [encodedHeader, encodedPayload, signature, ...rest] = token.split('.');
307+
308+
if (!encodedHeader || !encodedPayload || rest.length > 0) {
309+
throw new Error('Invalid JWT format.');
310+
}
311+
312+
return signature;
279313
}
314+
315+
/**
316+
* Decodes part of a JWT (header or payload) and ensures that the decoded value is an object.
317+
* @param token - The JWT to decode.
318+
* @param options - Options for decoding, e.g. whether to decode the header or payload.
319+
* @param options.header - If true, decodes the header; otherwise, decodes the payload.
320+
* @returns The decoded JWT part as an object.
321+
* @throws An error if the decoded value is not an object.
322+
* @internal
323+
*/
324+
function decodeJwtPart<T extends object>(
325+
token: string,
326+
options?: { header?: boolean }
327+
): T {
328+
const decoded = jwtDecode<T>(token, options);
329+
330+
if (typeof decoded !== 'object' || decoded === null) {
331+
throw new Error('Invalid JWT content.');
332+
}
333+
334+
return decoded;
335+
}

packages/generator-common/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,8 @@
4242
"dependencies": {
4343
"@sap-cloud-sdk/util": "workspace:^",
4444
"fast-levenshtein": "~3.0.0",
45-
"fs-extra": "^11.3.4",
4645
"glob": "^13.0.6",
4746
"prettier": "^3.8.1",
48-
"voca": "^1.4.1",
4947
"yargs": "^17.7.2"
5048
},
5149
"devDependencies": {

packages/generator-common/src/sdk-metadata/util.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { resolve } from 'path';
2-
import { readFile } from 'fs-extra';
2+
import { readFile } from 'node:fs/promises';
33

44
/**
55
* Get the current SDK version from the package json.

packages/generator-common/src/util.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { codeBlock, createLogger } from '@sap-cloud-sdk/util';
2-
import voca from 'voca';
1+
import { codeBlock, createLogger, titleFormat } from '@sap-cloud-sdk/util';
32

43
/**
54
* @returns A copyright header
@@ -80,7 +79,7 @@ function transformUnscopedName(packageName: string) {
8079
* @internal
8180
*/
8281
export function directoryToSpeakingModuleName(packageName: string): string {
83-
return voca.titleCase(packageName.replace(/[-,_]/g, ' '));
82+
return titleFormat(packageName.replace(/[-,_]/g, ' '));
8483
}
8584

8685
/**

0 commit comments

Comments
 (0)