Skip to content

Commit 67257e4

Browse files
committed
chore: Reduce dependency footprint
1 parent 1ab2232 commit 67257e4

24 files changed

Lines changed: 464 additions & 4460 deletions

File tree

.github/actions/check-public-api/index.js

Lines changed: 119 additions & 4316 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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-accessor-failure-cases.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ describe('Failure cases', () => {
5151
jwt: 'fails',
5252
cacheVerificationKeys: false
5353
})
54-
).rejects.toThrowErrorMatchingInlineSnapshot(
55-
'"JwtError: The given jwt payload does not encode valid JSON."'
56-
);
54+
).rejects.toThrowErrorMatchingInlineSnapshot(`
55+
"JwtError: The given jwt payload does not encode valid JSON.
56+
Cause: Invalid JWT format."
57+
`);
5758
});
5859

5960
it('throws an error if the subaccount/instance destinations call fails', async () => {

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

Lines changed: 19 additions & 3 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

@@ -619,7 +620,22 @@ describe('destination service', () => {
619620
}
620621
);
621622
expect(actual).toEqual(parseDestination(response));
622-
});
623+
}, 10000);
624+
625+
it('stops retrying after the configured number of attempts for 500 errors', async () => {
626+
const mock = nock(destinationServiceUri)
627+
.get('/destination-configuration/v1/destinations/HTTP-BASIC')
628+
.times(3)
629+
.reply(500);
630+
631+
await expect(
632+
fetchDestinationWithTokenRetrieval(destinationServiceUri, jwt, {
633+
destinationName: 'HTTP-BASIC',
634+
retry: true
635+
})
636+
).rejects.toThrow();
637+
expect(mock.isDone()).toBe(true);
638+
}, 15000);
623639

624640
it('does no retry if request fails with 401 error', async () => {
625641
const response = {
@@ -688,7 +704,7 @@ describe('destination service', () => {
688704
}
689705
);
690706
expect(actual).toMatchObject(parseDestination(responseValidToken));
691-
});
707+
}, 10000);
692708

693709
it('does a retry if auth tokens are failing but returns the destination with errors in the end', async () => {
694710
const response = {
@@ -723,7 +739,7 @@ describe('destination service', () => {
723739
);
724740
expect(actual.authTokens![0].error).toEqual('ERROR');
725741
expect(mock.isDone()).toBe(true);
726-
}, 10000);
742+
}, 15000);
727743

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

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

Lines changed: 61 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,58 @@ 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+
const sleep = (ms: number): Promise<void> =>
316+
new Promise(resolve => setTimeout(resolve, ms));
317+
318+
async function withRetry<T>(
319+
fn: (bail: (err: Error) => never, attempt: number) => Promise<T>,
320+
options: {
321+
retries?: number;
322+
onRetry?: (err: Error, attempt: number) => void;
323+
randomize?: boolean;
324+
} = {}
325+
): Promise<T> {
326+
const maxRetries = options.retries ?? 3;
327+
const randomize = Number(options.randomize ?? true);
328+
329+
class BailError extends Error {
330+
constructor(readonly cause: Error) {
331+
super(cause.message);
332+
}
333+
}
334+
335+
for (let attempt = 0; attempt < maxRetries - 1; attempt++) {
336+
try {
337+
return await fn(err => {
338+
throw new BailError(err);
339+
}, attempt);
340+
} catch (error) {
341+
if (error instanceof BailError) {
342+
throw error.cause;
343+
}
344+
options.onRetry?.(error as Error, attempt + 1);
345+
// Exponential backoff with optional randomization (factor 1-2x)
346+
const scalingFactor = 1 + randomize * Math.random();
347+
const backoff = Math.round(scalingFactor * 1000 * 2 ** attempt);
348+
await sleep(backoff);
349+
}
350+
}
351+
352+
return fn(err => {
353+
throw new BailError(err);
354+
}, maxRetries - 1);
355+
}
356+
306357
function retryDestination(
307358
destinationName: string
308359
): Middleware<
@@ -311,20 +362,21 @@ function retryDestination(
311362
MiddlewareContext<RawAxiosRequestConfig>
312363
> {
313364
return options => arg => {
314-
let retryCount = 1;
315-
return asyncRetry(
316-
async bail => {
365+
const maxRetries = 3;
366+
return withRetry(
367+
async (bail, attempt) => {
317368
try {
318369
const destination = await options.fn(arg);
319-
if (retryCount < 3) {
320-
retryCount++;
370+
if (attempt < maxRetries - 1) {
321371
// 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.
322372
await buildAuthorizationHeaders(parseDestination(destination.data));
323373
}
324374
return destination;
325375
} catch (error) {
326-
const status = error?.response?.status;
327-
if (status.toString().startsWith('4')) {
376+
const status = axios.isAxiosError(error)
377+
? error.response?.status
378+
: undefined;
379+
if (status?.toString().startsWith('4')) {
328380
bail(
329381
new ErrorWithCause(
330382
`Request failed with status code ${status}`,
@@ -338,7 +390,7 @@ function retryDestination(
338390
}
339391
},
340392
{
341-
retries: 3,
393+
retries: maxRetries,
342394
onRetry: (err: Error) =>
343395
logger.warn(
344396
`Failed to retrieve destination ${destinationName} - doing a retry. Original Error ${err.message}`

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

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

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
/**

packages/generator/package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,9 @@
4848
"@sap-cloud-sdk/odata-v2": "workspace:^",
4949
"@sap-cloud-sdk/odata-v4": "workspace:^",
5050
"@sap-cloud-sdk/util": "workspace:^",
51-
"@types/fs-extra": "^11.0.4",
5251
"fast-xml-parser": "^5.5.9",
53-
"fs-extra": "^11.3.4",
5452
"ts-morph": "^28.0.0",
5553
"typescript": "~5.9.3",
56-
"voca": "^1.4.1",
5754
"winston": "^3.19.0"
5855
},
5956
"devDependencies": {

0 commit comments

Comments
 (0)