diff --git a/src/access/infra/repositories/AccessRepository.ts b/src/access/infra/repositories/AccessRepository.ts index fbb288fd..5e208ff7 100644 --- a/src/access/infra/repositories/AccessRepository.ts +++ b/src/access/infra/repositories/AccessRepository.ts @@ -1,4 +1,11 @@ +import { ApiConfig, DataverseApiAuthMechanism } from '../../../core/infra/repositories/ApiConfig' +import { WriteError } from '../../../core/domain/repositories/WriteError' +import { ApiConstants } from '../../../core/infra/repositories/ApiConstants' import { ApiRepository } from '../../../core/infra/repositories/ApiRepository' +import { + buildRequestConfig, + buildRequestUrl +} from '../../../core/infra/repositories/apiConfigBuilders' import { GuestbookResponseDTO } from '../../domain/dtos/GuestbookResponseDTO' import { IAccessRepository } from '../../domain/repositories/IAccessRepository' @@ -13,14 +20,7 @@ export class AccessRepository extends ApiRepository implements IAccessRepository const endpoint = this.buildApiEndpoint(`${this.accessResourceName}/datafile`, undefined, fileId) const queryParams = format ? { signed: true, format } : { signed: true } - return this.doPost(endpoint, guestbookResponse, queryParams) - .then((response) => { - const signedUrl = response.data.data.signedUrl - return signedUrl - }) - .catch((error) => { - throw error - }) + return await this.submitGuestbookDownload(endpoint, guestbookResponse, queryParams) } public async submitGuestbookForDatafilesDownload( @@ -30,7 +30,7 @@ export class AccessRepository extends ApiRepository implements IAccessRepository ): Promise { const queryParams = format ? { signed: true, format } : { signed: true } - return this.doPost( + return await this.submitGuestbookDownload( this.buildApiEndpoint( this.accessResourceName, `datafiles/${Array.isArray(fileIds) ? fileIds.join(',') : fileIds}` @@ -38,13 +38,6 @@ export class AccessRepository extends ApiRepository implements IAccessRepository guestbookResponse, queryParams ) - .then((response) => { - const signedUrl = response.data.data.signedUrl - return signedUrl - }) - .catch((error) => { - throw error - }) } public async submitGuestbookForDatasetDownload( @@ -59,14 +52,7 @@ export class AccessRepository extends ApiRepository implements IAccessRepository ) const queryParams = format ? { signed: true, format } : { signed: true } - return this.doPost(endpoint, guestbookResponse, queryParams) - .then((response) => { - const signedUrl = response.data.data.signedUrl - return signedUrl - }) - .catch((error) => { - throw error - }) + return await this.submitGuestbookDownload(endpoint, guestbookResponse, queryParams) } public async submitGuestbookForDatasetVersionDownload( @@ -82,13 +68,96 @@ export class AccessRepository extends ApiRepository implements IAccessRepository ) const queryParams = format ? { signed: true, format } : { signed: true } - return this.doPost(endpoint, guestbookResponse, queryParams) - .then((response) => { - const signedUrl = response.data.data.signedUrl - return signedUrl - }) - .catch((error) => { - throw error - }) + return await this.submitGuestbookDownload(endpoint, guestbookResponse, queryParams) + } + + private async submitGuestbookDownload( + apiEndpoint: string, + guestbookResponse: GuestbookResponseDTO, + queryParams: object + ): Promise { + const requestConfig = buildRequestConfig( + true, + queryParams, + ApiConstants.CONTENT_TYPE_APPLICATION_JSON + ) + const response = await fetch( + this.buildUrlWithQueryParams(buildRequestUrl(apiEndpoint), queryParams), + { + method: 'POST', + headers: this.buildFetchHeaders(requestConfig.headers), + credentials: this.getFetchCredentials(requestConfig.withCredentials), + body: JSON.stringify(guestbookResponse) + } + ).catch((error) => { + throw new WriteError(error instanceof Error ? error.message : String(error)) + }) + + const responseData = await this.parseResponseBody(response) + + if (!response.ok) { + throw new WriteError(this.buildFetchErrorMessage(response.status, responseData)) + } + + return responseData.data.signedUrl as string + } + + private getFetchCredentials(withCredentials?: boolean): RequestCredentials | undefined { + if (ApiConfig.dataverseApiAuthMechanism === DataverseApiAuthMechanism.BEARER_TOKEN) { + return 'omit' + } + + if (withCredentials) { + return 'include' + } + + return undefined + } + + private buildUrlWithQueryParams(requestUrl: string, queryParams: object): string { + const url = new URL(requestUrl) + + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.append(key, String(value)) + } + }) + + return url.toString() + } + + private buildFetchHeaders(headers?: Record): Record { + const fetchHeaders: Record = {} + + if (!headers) { + return fetchHeaders + } + + Object.entries(headers).forEach(([key, value]) => { + if (value !== undefined) { + fetchHeaders[key] = String(value) + } + }) + + return fetchHeaders + } + + private async parseResponseBody(response: Response): Promise { + const contentType = response.headers.get('content-type') ?? '' + + if (contentType.includes('application/json')) { + return await response.json() + } + + return await response.text() + } + + private buildFetchErrorMessage(status: number, responseData: any): string { + const message = + typeof responseData === 'string' + ? responseData + : responseData?.message || responseData?.data?.message || 'unknown error' + + return `[${status}] ${message}` } } diff --git a/test/unit/access/AccessRepository.test.ts b/test/unit/access/AccessRepository.test.ts new file mode 100644 index 00000000..24fd97f2 --- /dev/null +++ b/test/unit/access/AccessRepository.test.ts @@ -0,0 +1,69 @@ +/** + * @jest-environment jsdom + */ + +import { AccessRepository } from '../../../src/access/infra/repositories/AccessRepository' +import { GuestbookResponseDTO } from '../../../src/access/domain/dtos/GuestbookResponseDTO' +import { + ApiConfig, + DataverseApiAuthMechanism +} from '../../../src/core/infra/repositories/ApiConfig' +import { TestConstants } from '../../testHelpers/TestConstants' + +describe('AccessRepository', () => { + const sut = new AccessRepository() + const guestbookResponse: GuestbookResponseDTO = { + guestbookResponse: { + answers: [{ id: 1, value: 'question 1' }] + } + } + + beforeEach(() => { + window.localStorage.setItem( + TestConstants.TEST_BEARER_TOKEN_LOCAL_STORAGE_KEY, + JSON.stringify(TestConstants.TEST_DUMMY_BEARER_TOKEN) + ) + }) + + afterEach(() => { + window.localStorage.clear() + }) + + test('uses fetch with credentials omit for bearer token auth', async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.BEARER_TOKEN, + undefined, + TestConstants.TEST_BEARER_TOKEN_LOCAL_STORAGE_KEY + ) + + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: jest.fn().mockResolvedValue({ + data: { + signedUrl: 'https://signed.dataset' + } + }) + } as unknown as Response) + + global.fetch = fetchMock as typeof fetch + + const actual = await sut.submitGuestbookForDatasetDownload(123, guestbookResponse, 'original') + + expect(fetchMock).toHaveBeenCalledWith( + `${TestConstants.TEST_API_URL}/access/dataset/123?signed=true&format=original`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${TestConstants.TEST_DUMMY_BEARER_TOKEN}` + }, + credentials: 'omit', + body: JSON.stringify(guestbookResponse) + } + ) + expect(actual).toBe('https://signed.dataset') + }) +})