Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,441 changes: 125 additions & 4,316 deletions .github/actions/check-public-api/index.js

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions packages/connectivity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,9 @@
"@sap-cloud-sdk/util": "workspace:^",
"@sap/xsenv": "^6.2.0",
"@sap/xssec": "^4.13.0",
"async-retry": "^1.3.3",
"axios": "^1.15.0",
"jks-js": "^1.1.6",
"jsonwebtoken": "^9.0.3",
"jwt-decode": "^4.0.0",
"safe-stable-stringify": "^2.5.0"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ describe('Failure cases', () => {
jwt: 'fails',
cacheVerificationKeys: false
})
).rejects.toThrowErrorMatchingInlineSnapshot(
'"JwtError: The given jwt payload does not encode valid JSON."'
);
).rejects.toThrowErrorMatchingInlineSnapshot(`
"JwtError: The given jwt payload does not encode valid JSON.
Cause: Invalid JWT format."
`);
});

it('throws an error if the subaccount/instance destinations call fails', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ describe('destination service', () => {

describe('fetchDestinationByToken', () => {
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});

Expand Down Expand Up @@ -619,7 +620,22 @@ describe('destination service', () => {
}
);
expect(actual).toEqual(parseDestination(response));
});
}, 10000);

it('stops retrying after the configured number of attempts for 500 errors', async () => {
const mock = nock(destinationServiceUri)
.get('/destination-configuration/v1/destinations/HTTP-BASIC')
.times(3)
.reply(500);

await expect(
fetchDestinationWithTokenRetrieval(destinationServiceUri, jwt, {
destinationName: 'HTTP-BASIC',
retry: true
})
).rejects.toThrow();
expect(mock.isDone()).toBe(true);
}, 15000);

it('does no retry if request fails with 401 error', async () => {
const response = {
Expand Down Expand Up @@ -688,7 +704,7 @@ describe('destination service', () => {
}
);
expect(actual).toMatchObject(parseDestination(responseValidToken));
});
}, 10000);

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

it('fetches a destination and returns 200 but authTokens are failing', async () => {
const destinationName = 'FINAL-DESTINATION';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
import axios from 'axios';
import { executeWithMiddleware } from '@sap-cloud-sdk/resilience/internal';
import { resilience } from '@sap-cloud-sdk/resilience';
import asyncRetry from 'async-retry';
import { decodeJwt, getTenantId, wrapJwtInHeader } from '../jwt';
import { urlAndAgent } from '../../http-agent';
import { buildAuthorizationHeaders } from '../authorization-header';
Expand Down Expand Up @@ -303,6 +302,58 @@ function errorMessageFromResponse(
: '';
}

/**
* @internal
* Retries a function with exponential backoff.
* @param fn - The function to retry.
* @param options - Options for retrying.
* @param options.retries - The maximum number of retries. Default is 3.
* @param options.onRetry - A callback function that is called before each retry with the error and the current attempt number.
* @param options.randomize - Whether to randomize the backoff time by a factor of 1-2. Default is true.
* @returns The result of the function.
*/
const sleep = (ms: number): Promise<void> =>
new Promise(resolve => setTimeout(resolve, ms));

async function withRetry<T>(
fn: (bail: (err: Error) => never, attempt: number) => Promise<T>,
options: {
retries?: number;
onRetry?: (err: Error, attempt: number) => void;
randomize?: boolean;
} = {}
): Promise<T> {
const maxRetries = options.retries ?? 3;
const randomize = Number(options.randomize ?? true);

class BailError extends Error {
constructor(readonly cause: Error) {
super(cause.message);
}
}

for (let attempt = 0; attempt < maxRetries - 1; attempt++) {
try {
return await fn(err => {
throw new BailError(err);
}, attempt);
} catch (error) {
if (error instanceof BailError) {
throw error.cause;
}
options.onRetry?.(error as Error, attempt + 1);
// Exponential backoff with optional randomization (factor 1-2x)
const scalingFactor = 1 + randomize * Math.random();
const backoff = Math.round(scalingFactor * 1000 * 2 ** attempt);
await sleep(backoff);
}
}

return fn(err => {
throw new BailError(err);
}, maxRetries - 1);
}

function retryDestination(
destinationName: string
): Middleware<
Expand All @@ -311,20 +362,21 @@ function retryDestination(
MiddlewareContext<RawAxiosRequestConfig>
> {
return options => arg => {
let retryCount = 1;
return asyncRetry(
async bail => {
const maxRetries = 3;
return withRetry(
async (bail, attempt) => {
try {
const destination = await options.fn(arg);
if (retryCount < 3) {
retryCount++;
if (attempt < maxRetries - 1) {
// 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.
await buildAuthorizationHeaders(parseDestination(destination.data));
}
return destination;
} catch (error) {
const status = error?.response?.status;
if (status.toString().startsWith('4')) {
const status = axios.isAxiosError(error)
? error.response?.status
: undefined;
if (status?.toString().startsWith('4')) {
bail(
new ErrorWithCause(
`Request failed with status code ${status}`,
Expand All @@ -338,7 +390,7 @@ function retryDestination(
}
},
{
retries: 3,
retries: maxRetries,
onRetry: (err: Error) =>
logger.warn(
`Failed to retrieve destination ${destinationName} - doing a retry. Original Error ${err.message}`
Expand Down
79 changes: 67 additions & 12 deletions packages/connectivity/src/scp-cf/jwt/jwt.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { createLogger, pickValueIgnoreCase } from '@sap-cloud-sdk/util';
import { decode } from 'jsonwebtoken';
import { jwtDecode } from 'jwt-decode';
import { Cache } from '../cache';
import { getIssuerSubdomain } from '../subdomain-replacer';
import type {
Jwt,
JwtHeader,
JwtPayload,
JwtWithPayloadObject
} from '../jsonwebtoken-type';
Expand Down Expand Up @@ -136,7 +136,7 @@ function audiencesFromAud({ aud }: JwtPayload): string[] {
}

function audiencesFromScope({ scope }: JwtPayload): string[] {
return makeArray(scope).reduce(
return makeArray(scope).reduce<string[]>(
(aud, s) => (s.includes('.') ? [...aud, s.split('.')[0]] : aud),
[]
);
Expand All @@ -148,7 +148,20 @@ function audiencesFromScope({ scope }: JwtPayload): string[] {
* @returns Decoded payload.
*/
export function decodeJwt(token: string | JwtPayload): JwtPayload {
return typeof token === 'string' ? decodeJwtComplete(token).payload : token;
if (typeof token !== 'string') {
return token;
}
try {
validateJwtFormat(token);
return decodeJwtPart<JwtPayload>(token);
} catch (error) {
throw new Error(
'JwtError: The given jwt payload does not encode valid JSON.',
{
cause: error
}
);
}
}

/**
Expand All @@ -158,13 +171,21 @@ export function decodeJwt(token: string | JwtPayload): JwtPayload {
* @internal
*/
export function decodeJwtComplete(token: string): JwtWithPayloadObject {
const decodedToken = decode(token, { complete: true, json: true });
if (decodedToken !== null && isJwtWithPayloadObject(decodedToken)) {
return decodedToken;
try {
const signature = validateJwtFormat(token);
return {
header: decodeJwtPart<JwtHeader>(token, { header: true }),
payload: decodeJwtPart<JwtPayload>(token),
signature
};
} catch (error) {
throw new Error(
'JwtError: The given jwt payload does not encode valid JSON.',
{
cause: error
}
);
}
throw new Error(
'JwtError: The given jwt payload does not encode valid JSON.'
);
}

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

function isJwtWithPayloadObject(decoded: Jwt): decoded is JwtWithPayloadObject {
return typeof decoded.payload !== 'string';
/**
* Validate the format of the given JWT and return the signature part if valid.
* @returns The signature part of the JWT if the format is valid.
* @throws An error if the JWT format is invalid.
* @internal
*/
function validateJwtFormat(token: string): string {
const [encodedHeader, encodedPayload, signature, ...rest] = token.split('.');

if (!encodedHeader || !encodedPayload || rest.length > 0) {
throw new Error('Invalid JWT format.');
}

return signature;
}

/**
* Decodes part of a JWT (header or payload) and ensures that the decoded value is an object.
* @param token - The JWT to decode.
* @param options - Options for decoding, e.g. whether to decode the header or payload.
* @param options.header - If true, decodes the header; otherwise, decodes the payload.
* @returns The decoded JWT part as an object.
* @throws An error if the decoded value is not an object.
* @internal
*/
function decodeJwtPart<T extends object>(
token: string,
options?: { header?: boolean }
): T {
const decoded = jwtDecode<T>(token, options);

if (typeof decoded !== 'object' || decoded === null) {
throw new Error('Invalid JWT content.');
}

return decoded;
}
2 changes: 0 additions & 2 deletions packages/generator-common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,8 @@
"dependencies": {
"@sap-cloud-sdk/util": "workspace:^",
"fast-levenshtein": "~3.0.0",
"fs-extra": "^11.3.4",
"glob": "^13.0.6",
"prettier": "^3.8.1",
"voca": "^1.4.1",
"yargs": "^17.7.2"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/generator-common/src/sdk-metadata/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { resolve } from 'path';
import { readFile } from 'fs-extra';
import { readFile } from 'node:fs/promises';

/**
* Get the current SDK version from the package json.
Expand Down
5 changes: 2 additions & 3 deletions packages/generator-common/src/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { codeBlock, createLogger } from '@sap-cloud-sdk/util';
import voca from 'voca';
import { codeBlock, createLogger, titleFormat } from '@sap-cloud-sdk/util';

/**
* @returns A copyright header
Expand Down Expand Up @@ -80,7 +79,7 @@ function transformUnscopedName(packageName: string) {
* @internal
*/
export function directoryToSpeakingModuleName(packageName: string): string {
return voca.titleCase(packageName.replace(/[-,_]/g, ' '));
return titleFormat(packageName.replace(/[-,_]/g, ' '));
}

/**
Expand Down
3 changes: 0 additions & 3 deletions packages/generator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,9 @@
"@sap-cloud-sdk/odata-v2": "workspace:^",
"@sap-cloud-sdk/odata-v4": "workspace:^",
"@sap-cloud-sdk/util": "workspace:^",
"@types/fs-extra": "^11.0.4",
"fast-xml-parser": "^5.5.9",
"fs-extra": "^11.3.4",
"ts-morph": "^28.0.0",
"typescript": "~5.9.3",
"voca": "^1.4.1",
"winston": "^3.19.0"
},
"devDependencies": {
Expand Down
10 changes: 4 additions & 6 deletions packages/generator/src/edmx-parser/v4/edmx-parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import voca from 'voca';
import { capitalize } from '@sap-cloud-sdk/util';
import {
getMergedPropertyWithNamespace,
getPropertyFromEntityContainer,
Expand Down Expand Up @@ -133,13 +133,11 @@ export function parseOperationImports(
root: any,
operationType: 'function' | 'action'
): EdmxOperationImport[] {
const operations = getPropertyFromEntityContainer(
root,
`${voca.capitalize(operationType)}Import`
);
const capType = capitalize(operationType);
const operations = getPropertyFromEntityContainer(root, `${capType}Import`);
return operations.map(operation => ({
...operation,
operationName: operation[voca.capitalize(operationType)],
operationName: operation[capType],
operationType
}));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { first } from '@sap-cloud-sdk/util';
import voca from 'voca';
import { first, decapitalize } from '@sap-cloud-sdk/util';
import { isNullableProperty } from '../../generator-utils';
// eslint-disable-next-line import-x/no-internal-modules
import { getApiName } from '../../generator-without-ts-morph/service';
Expand Down Expand Up @@ -134,9 +133,7 @@ function getEntityReturnType(
? {
returnTypeCategory: 'entity',
returnType: first(entities)!.className,
builderFunction: `${voca.decapitalize(
serviceName
)}(deSerializers).${getApiName(first(entities)!.className)}`,
builderFunction: `${decapitalize(serviceName)}(deSerializers).${getApiName(first(entities)!.className)}`,
isNullable,
isCollection
}
Expand Down
Loading
Loading