Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
* @asein-sinch @JPPortier @krogers0607 @Dovchik
* @asein-sinch @JPPortier @matsk-sinch
1 change: 1 addition & 0 deletions .github/workflows/run-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
token: ${{ secrets.PAT_CI }}
fetch-depth: 0
path: sinch-sdk-mockserver
ref: 213a7ab2cbb607467d2d912e74877b97159bb467

- name: Install Docker Compose
run: |
Expand Down
10 changes: 2 additions & 8 deletions packages/sdk-client/src/client/api-client-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ export const manageExpiredToken = async (
errorContext: ErrorContext,
requestPlugins: RequestPlugin[] | undefined,
requestOptions: RequestOptions,
callback: (props: any) => Promise<any>,
) => {
): Promise<RequestOptions> => {
// Use the circuitBreaker variable to try to regenerate a valid JWT only 3 times
if (!apiCallParameters.circuitBreaker) {
apiCallParameters.circuitBreaker = 1;
Expand All @@ -26,12 +25,7 @@ export const manageExpiredToken = async (
);
}
}
const optionsWithNewJwt = await invalidateAndRegenerateJwt(requestPlugins, requestOptions, errorContext);
const newApiCallParameters = {
...apiCallParameters,
requestOptions: optionsWithNewJwt,
};
return callback(newApiCallParameters);
return await invalidateAndRegenerateJwt(requestPlugins, requestOptions, errorContext);
};

export function buildErrorContext(
Expand Down
74 changes: 34 additions & 40 deletions packages/sdk-client/src/client/api-fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,18 @@ export class ApiFetchClient extends ApiClient {

// Execute call
try {
// Send the request with the refresh token mechanism
response = await this.sinchFetch(props, errorContext);
response = await fetch(props.url, props.requestOptions);
if (
response.status === 401
&& response.headers.get('www-authenticate')?.includes('expired')
) {
const requestOptions = await manageExpiredToken(
props,
errorContext,
this.apiClientOptions.requestPlugins,
props.requestOptions);
response = await fetch(props.url, requestOptions);
}
body = await response.text();
} catch (error: any) {
this.buildFetchError(error, errorContext);
Expand Down Expand Up @@ -103,53 +113,26 @@ export class ApiFetchClient extends ApiClient {
return reviveDates(transformedResponse);
}

private async sinchFetch(
apiCallParameters: ApiCallParameters,
errorContext: ErrorContext,
): Promise<Response> {
const response = await fetch(apiCallParameters.url, apiCallParameters.requestOptions);
if (
response.status === 401
&& response.headers.get('www-authenticate')?.includes('expired')
) {
return manageExpiredToken(
apiCallParameters,
errorContext,
this.apiClientOptions.requestPlugins,
apiCallParameters.requestOptions,
this.processCall);
}
return response;
}

public processCallWithPagination<T>(
props: ApiCallParametersWithPagination,
public async processCallWithPagination<T>(
apiCallParameters: ApiCallParametersWithPagination,
): Promise<PageResult<T>> {
// Read the "Origin" header if existing, for logging purposes
const origin = (props.requestOptions.headers as Headers).get('Origin');
const errorContext: ErrorContext = buildErrorContext(props, origin);
const origin = (apiCallParameters.requestOptions.headers as Headers).get('Origin');
const errorContext: ErrorContext = buildErrorContext(apiCallParameters, origin);
let exception: Error | undefined;

// Execute call
return this.sinchFetchWithPagination<T>(props, errorContext, origin);
};

private async sinchFetchWithPagination<T>(
apiCallParameters: ApiCallParametersWithPagination,
errorContext: ErrorContext,
origin: string | null,
): Promise<PageResult<T>> {
let exception: Error | undefined;
const response = await fetch(apiCallParameters.url, apiCallParameters.requestOptions);
let response = await fetch(apiCallParameters.url, apiCallParameters.requestOptions);
if (
response.status === 401
&& response.headers.get('www-authenticate')?.includes('expired')
) {
return manageExpiredToken(
const requestOptions = await manageExpiredToken(
apiCallParameters,
errorContext,
this.apiClientOptions.requestPlugins,
apiCallParameters.requestOptions,
this.processCallWithPagination);
apiCallParameters.requestOptions);
response = await fetch(apiCallParameters.url, requestOptions);
}
// When handling pagination, we won't return the raw response but a PageResult
const body = await response.text();
Expand Down Expand Up @@ -197,7 +180,7 @@ export class ApiFetchClient extends ApiClient {
nextPage: () => createNextPageMethod<T>(
this, buildPaginationContext(apiCallParameters), apiCallParameters.requestOptions, nextPage),
};
}
};

private buildFetchError(error: any, errorContext: ErrorContext): Error {
if (error instanceof GenericError) {
Expand Down Expand Up @@ -249,7 +232,18 @@ export class ApiFetchClient extends ApiClient {
// Execute call
try {
// Send the request with the refresh token mechanism
response = await this.sinchFetch(props, errorContext);
response = await fetch(props.url, props.requestOptions);
if (
response.status === 401
&& response.headers.get('www-authenticate')?.includes('expired')
) {
const requestOptions = await manageExpiredToken(
props,
errorContext,
this.apiClientOptions.requestPlugins,
props.requestOptions);
response = await fetch(props.url, requestOptions);
}
body = await response.buffer();
fileName = this.extractFileName(response.headers);
} catch (error: any) {
Expand Down
181 changes: 181 additions & 0 deletions packages/sdk-client/tests/client/api-fetch-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
jest.mock('node-fetch', () => {
const actual = jest.requireActual('node-fetch');
return {
__esModule: true,
default: jest.fn(),
Headers: actual.Headers,
Response: actual.Response,
};
});

import { RequestPluginEnum } from '../../src/plugins/core/request-plugin';
import { ApiFetchClient, PaginationEnum } from '../../src';
import fetch, { Headers, Response } from 'node-fetch';

const mockedFetch = fetch as unknown as jest.Mock;

describe('manageExpiredToken', () => {

let token: string | undefined;
let apiClient: ApiFetchClient;
let oauth2Plugin: any;

beforeEach(() => {
jest.clearAllMocks();

oauth2Plugin = {
getName: () => RequestPluginEnum.OAUTH2_TOKEN_REQUEST,
invalidateToken: jest.fn(() => {
token = undefined;
}),
load: () => ({
transform: async (options: any) => {
if (!token) {
token = 'new-token';
}
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${token}`);
return { ...options, headers };
},
}),
};

apiClient = new ApiFetchClient({
requestPlugins: [oauth2Plugin],
});

token = 'expired-token';
});

it('should regenerate JWT and retry the call after token expiration', async () => {

// Fake fetch simulation (first call 401, second call 200)
mockedFetch.mockResolvedValueOnce(
new Response('', {
status: 401,
headers: { 'www-authenticate': 'token expired' },
}),
);
// Second call — success
mockedFetch.mockResolvedValue(
new Response(JSON.stringify({ data: 'success' }), {
status: 200,
headers: {},
}),
);

const headers = new Headers({ Authorization: `Bearer ${token}` });
const requestOptions = {
method: 'GET',
headers,
hostname: 'https://api.example.com',
path: '/endpoint',
};

const result = await apiClient.processCall<{ data: string }>({
url: 'https://api.example.com/endpoint',
requestOptions,
apiName: 'TestAPI',
operationId: 'testOperation',
});

expect(result).toEqual({ data: 'success' });
expect(oauth2Plugin.invalidateToken).toHaveBeenCalledTimes(1);
expect(mockedFetch).toHaveBeenCalledTimes(2);
expect(mockedFetch.mock.calls[0][1].headers.get('Authorization')).toBe('Bearer expired-token');
expect(mockedFetch.mock.calls[1][1].headers.get('Authorization')).toBe('Bearer new-token');
});

it('should regenerate JWT and retry the call after token expiration for paginated endpoint', async () => {

// Fake fetch simulation (first call 401, second call 200)
mockedFetch.mockResolvedValueOnce(
new Response('', {
status: 401,
headers: { 'www-authenticate': 'token expired' },
}),
);
// Second call — success
mockedFetch.mockResolvedValue(
new Response(JSON.stringify({ data: ['success'], count: 1, page: 1, page_size: 2 }), {
status: 200,
headers: {},
}),
);

const headers = new Headers({ Authorization: `Bearer ${token}` });
const requestOptions = {
method: 'GET',
headers,
hostname: 'https://api.example.com',
path: '/endpoint',
};

const result = await apiClient.processCallWithPagination<{ data: string }>({
url: 'https://api.example.com/endpoint',
requestOptions,
apiName: 'TestAPI',
operationId: 'testOperation',
pagination: PaginationEnum.PAGE,
dataKey: 'data',
});

const expectedResponse = {
data: ['success'],
hasNextPage: false,
nextPageValue: '"2"',
};

expect(result).toMatchObject(expectedResponse);
expect(typeof result.nextPage).toBe('function');
expect(oauth2Plugin.invalidateToken).toHaveBeenCalledTimes(1);
expect(mockedFetch).toHaveBeenCalledTimes(2);
expect(mockedFetch.mock.calls[0][1].headers.get('Authorization')).toBe('Bearer expired-token');
expect(mockedFetch.mock.calls[1][1].headers.get('Authorization')).toBe('Bearer new-token');
});

it('should regenerate JWT and retry the call after token expiration for file call', async () => {

// Fake fetch simulation (first call 401, second call 200)
mockedFetch.mockResolvedValueOnce(
new Response('', {
status: 401,
headers: { 'www-authenticate': 'token expired' },
}),
);
// Second call — success
mockedFetch.mockResolvedValue(
new Response('responseBuffer', {
status: 200,
headers: {},
}),
);

const headers = new Headers({ Authorization: `Bearer ${token}` });
const requestOptions = {
method: 'GET',
headers,
hostname: 'https://api.example.com',
path: '/endpoint',
};

const result = await apiClient.processFileCall({
url: 'https://api.example.com/endpoint',
requestOptions,
apiName: 'TestAPI',
operationId: 'testOperation',
});

const expectedResponse = {
buffer: Buffer.from('responseBuffer', 'utf-8'),
fileName: 'default-name.pdf',
};

expect(result).toMatchObject(expectedResponse);
expect(oauth2Plugin.invalidateToken).toHaveBeenCalledTimes(1);
expect(mockedFetch).toHaveBeenCalledTimes(2);
expect(mockedFetch.mock.calls[0][1].headers.get('Authorization')).toBe('Bearer expired-token');
expect(mockedFetch.mock.calls[1][1].headers.get('Authorization')).toBe('Bearer new-token');
});

});
Loading