From 6ed4802f232767a9a43fdf5793475495b38a9c98 Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Tue, 28 Apr 2026 15:09:23 -0400 Subject: [PATCH 1/2] feat: fileCitationFile use case --- CHANGELOG.md | 2 + docs/useCases.md | 32 +++++ src/files/domain/models/FileCitationFormat.ts | 7 + .../domain/repositories/IFilesRepository.ts | 3 + .../useCases/GetFileCitationByFormat.ts | 22 ++++ src/files/index.ts | 4 + .../infra/repositories/FilesRepository.ts | 17 +++ test/environment/.env | 4 +- .../files/GetFileCitationByFormat.test.ts | 123 ++++++++++++++++++ .../integration/files/FilesRepository.test.ts | 50 +++++++ test/unit/files/FilesRepository.test.ts | 79 +++++++++++ .../files/GetFileCitationByFormat.test.ts | 58 +++++++++ 12 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 src/files/domain/models/FileCitationFormat.ts create mode 100644 src/files/domain/useCases/GetFileCitationByFormat.ts create mode 100644 test/functional/files/GetFileCitationByFormat.test.ts create mode 100644 test/unit/files/GetFileCitationByFormat.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d042fa65..ea1e7454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel ### Added +- Files: Added `getFileCitationByFormat` use case, repository method, and `FileCitationFormat` enum to support Dataverse file citation exports in `EndNote`, `RIS`, `BibTeX`, `CSL`, and `Internal` formats. + ### Changed ### Fixed diff --git a/docs/useCases.md b/docs/useCases.md index 4fb7a6f1..fbc9f553 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -71,6 +71,7 @@ The different use cases currently available in the package are classified below, - [Get a File](#get-a-file) - [Get a File and its Dataset](#get-a-file-and-its-dataset) - [Get File Citation Text](#get-file-citation-text) + - [Get File Citation By Format](#get-file-citation-by-format) - [Get File Counts in a Dataset](#get-file-counts-in-a-dataset) - [Get File Data Tables](#get-file-data-tables) - [Get File Download Count](#get-file-download-count) @@ -1710,6 +1711,37 @@ The `fileId` parameter can be a string, for persistent identifiers, or a number, There is an optional third parameter called `includeDeaccessioned`, which indicates whether to consider deaccessioned versions or not in the file search. If not set, the default value is `false`. +#### Get File Citation By Format + +Returns the File citation in the requested citation export format. + +##### Example call: + +```typescript +import { + FileCitationFormat, + getFileCitationByFormat +} from '@iqss/dataverse-client-javascript' + +/* ... */ + +const fileId = 3 + +getFileCitationByFormat + .execute(fileId, FileCitationFormat.BIBTEX) + .then((citationText: string) => { + /* ... */ + }) + +/* ... */ +``` + +_See [use case](../src/files/domain/useCases/GetFileCitationByFormat.ts) implementation_. + +The `fileId` parameter can be a string, for persistent identifiers, or a number, for numeric identifiers. + +The `format` parameter must be one of the available [FileCitationFormat](../src/files/domain/models/FileCitationFormat.ts) enum values: `EndNote`, `RIS`, `BibTeX`, `CSL`, or `Internal`. + #### Get File Counts in a Dataset Returns an instance of [FileCounts](../src/files/domain/models/FileCounts.ts), containing the requested Dataset total file count, as well as file counts for the following file properties: diff --git a/src/files/domain/models/FileCitationFormat.ts b/src/files/domain/models/FileCitationFormat.ts new file mode 100644 index 00000000..ffe657ee --- /dev/null +++ b/src/files/domain/models/FileCitationFormat.ts @@ -0,0 +1,7 @@ +export enum FileCitationFormat { + ENDNOTE = 'EndNote', + RIS = 'RIS', + BIBTEX = 'BibTeX', + CSL = 'CSL', + INTERNAL = 'Internal' +} diff --git a/src/files/domain/repositories/IFilesRepository.ts b/src/files/domain/repositories/IFilesRepository.ts index 4890a38b..29f78d6b 100644 --- a/src/files/domain/repositories/IFilesRepository.ts +++ b/src/files/domain/repositories/IFilesRepository.ts @@ -11,6 +11,7 @@ import { UploadedFileDTO } from '../dtos/UploadedFileDTO' import { UpdateFileMetadataDTO } from '../dtos/UpdateFileMetadataDTO' import { RestrictFileDTO } from '../dtos/RestrictFileDTO' import { FileVersionSummarySubset } from '../models/FileVersionSummaryInfo' +import { FileCitationFormat } from '../models/FileCitationFormat' export interface IFilesRepository { getDatasetFiles( @@ -57,6 +58,8 @@ export interface IFilesRepository { includeDeaccessioned: boolean ): Promise + getFileCitationByFormat(fileId: number | string, format: FileCitationFormat): Promise + getFileUploadDestination(datasetId: number | string, file: File): Promise addUploadedFilesToDataset( diff --git a/src/files/domain/useCases/GetFileCitationByFormat.ts b/src/files/domain/useCases/GetFileCitationByFormat.ts new file mode 100644 index 00000000..04dc885e --- /dev/null +++ b/src/files/domain/useCases/GetFileCitationByFormat.ts @@ -0,0 +1,22 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { IFilesRepository } from '../repositories/IFilesRepository' +import { FileCitationFormat } from '../models/FileCitationFormat' + +export class GetFileCitationByFormat implements UseCase { + private filesRepository: IFilesRepository + + constructor(filesRepository: IFilesRepository) { + this.filesRepository = filesRepository + } + + /** + * Returns the File citation in the requested format (EndNote XML, RIS, BibTeX, CSL JSON, or Internal HTML). + * + * @param {number | string} [fileId] - The File identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers). + * @param {FileCitationFormat} [format] - The citation format to return. + * @returns {Promise} + */ + async execute(fileId: number | string, format: FileCitationFormat): Promise { + return await this.filesRepository.getFileCitationByFormat(fileId, format) + } +} diff --git a/src/files/index.ts b/src/files/index.ts index a9d38386..f49a0ea3 100644 --- a/src/files/index.ts +++ b/src/files/index.ts @@ -7,6 +7,7 @@ import { GetFileDataTables } from './domain/useCases/GetFileDataTables' import { GetDatasetFilesTotalDownloadSize } from './domain/useCases/GetDatasetFilesTotalDownloadSize' import { GetFile } from './domain/useCases/GetFile' import { GetFileCitation } from './domain/useCases/GetFileCitation' +import { GetFileCitationByFormat } from './domain/useCases/GetFileCitationByFormat' import { GetFileAndDataset } from './domain/useCases/GetFileAndDataset' import { UploadFile } from './domain/useCases/UploadFile' import { DirectUploadClient } from './infra/clients/DirectUploadClient' @@ -32,6 +33,7 @@ const getDatasetFilesTotalDownloadSize = new GetDatasetFilesTotalDownloadSize(fi const getFile = new GetFile(filesRepository) const getFileAndDataset = new GetFileAndDataset(filesRepository) const getFileCitation = new GetFileCitation(filesRepository) +const getFileCitationByFormat = new GetFileCitationByFormat(filesRepository) const uploadFile = new UploadFile(directUploadClient) const addUploadedFilesToDataset = new AddUploadedFilesToDataset(filesRepository) const deleteFile = new DeleteFile(filesRepository) @@ -53,6 +55,7 @@ export { getFile, getFileAndDataset, getFileCitation, + getFileCitationByFormat, uploadFile, addUploadedFilesToDataset, deleteFile, @@ -89,6 +92,7 @@ export { FileDataVariableFormatType } from './domain/models/FileDataTable' export { FileDownloadSizeMode } from './domain/models/FileDownloadSizeMode' +export { FileCitationFormat } from './domain/models/FileCitationFormat' export { FilesSubset } from './domain/models/FilesSubset' export { FilePreview, FilePreviewChecksum } from './domain/models/FilePreview' export { UploadedFileDTO } from './domain/dtos/UploadedFileDTO' diff --git a/src/files/infra/repositories/FilesRepository.ts b/src/files/infra/repositories/FilesRepository.ts index 00b70ba8..fbff81e6 100644 --- a/src/files/infra/repositories/FilesRepository.ts +++ b/src/files/infra/repositories/FilesRepository.ts @@ -24,6 +24,7 @@ import { ApiConstants } from '../../../core/infra/repositories/ApiConstants' import { RestrictFileDTO } from '../../domain/dtos/RestrictFileDTO' import { FileVersionSummarySubset } from '../../domain/models/FileVersionSummaryInfo' import { transformFileVersionSummaryInfoResponseToFileVersionSummaryInfo } from './transformers/fileVersionSummaryInfoTransformers' +import { FileCitationFormat } from '../../domain/models/FileCitationFormat' export interface GetFilesQueryParams { includeDeaccessioned: boolean @@ -234,6 +235,22 @@ export class FilesRepository extends ApiRepository implements IFilesRepository { }) } + public async getFileCitationByFormat( + fileId: number | string, + format: FileCitationFormat + ): Promise { + return this.doGet( + this.buildApiEndpoint(this.accessResourceName, `citation/${format}`, fileId), + true + ) + .then((response) => + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + ) + .catch((error) => { + throw error + }) + } + public async getFileUploadDestination( datasetId: number | string, file: File diff --git a/test/environment/.env b/test/environment/.env index e7b54bde..cc0e9d45 100644 --- a/test/environment/.env +++ b/test/environment/.env @@ -1,6 +1,6 @@ POSTGRES_VERSION=17 DATAVERSE_DB_USER=dataverse SOLR_VERSION=9.8.0 -DATAVERSE_IMAGE_REGISTRY=docker.io -DATAVERSE_IMAGE_TAG=unstable +DATAVERSE_IMAGE_REGISTRY=ghcr.io +DATAVERSE_IMAGE_TAG=11733-api-get-file-citation-format DATAVERSE_BOOTSTRAP_TIMEOUT=5m diff --git a/test/functional/files/GetFileCitationByFormat.test.ts b/test/functional/files/GetFileCitationByFormat.test.ts new file mode 100644 index 00000000..643a0faf --- /dev/null +++ b/test/functional/files/GetFileCitationByFormat.test.ts @@ -0,0 +1,123 @@ +import { + ApiConfig, + createDataset, + CreatedDatasetIdentifiers, + FileCitationFormat, + getDatasetFiles, + getFileCitationByFormat, + ReadError +} from '../../../src' +import { DataverseApiAuthMechanism } from '../../../src/core/infra/repositories/ApiConfig' +import { + createCollectionViaApi, + deleteCollectionViaApi +} from '../../testHelpers/collections/collectionHelper' +import { deleteUnpublishedDatasetViaApi } from '../../testHelpers/datasets/datasetHelper' +import { uploadFileViaApi } from '../../testHelpers/files/filesHelper' +import { TestConstants } from '../../testHelpers/TestConstants' + +describe('execute', () => { + const testCollectionAlias = 'getFileCitationByFormatFunctionalTest' + const testTextFile1Name = 'test-file-1.txt' + let testDatasetIds: CreatedDatasetIdentifiers + + beforeAll(async () => { + ApiConfig.init( + TestConstants.TEST_API_URL, + DataverseApiAuthMechanism.API_KEY, + process.env.TEST_API_KEY + ) + await createCollectionViaApi(testCollectionAlias) + + try { + testDatasetIds = await createDataset.execute( + TestConstants.TEST_NEW_DATASET_DTO, + testCollectionAlias + ) + } catch (error) { + throw new Error('Tests beforeAll(): Error while creating test dataset') + } + + await uploadFileViaApi(testDatasetIds.numericId, testTextFile1Name).catch(() => { + throw new Error(`Tests beforeAll(): Error while uploading file ${testTextFile1Name}`) + }) + }) + + afterAll(async () => { + try { + await deleteUnpublishedDatasetViaApi(testDatasetIds.numericId) + } catch (error) { + throw new Error('Tests afterAll(): Error while deleting test dataset') + } + + try { + await deleteCollectionViaApi(testCollectionAlias) + } catch (error) { + throw new Error('Tests afterAll(): Error while deleting test collection') + } + }) + + const getTestFileId = async (): Promise => { + const datasetFiles = await getDatasetFiles.execute(testDatasetIds.numericId) + return datasetFiles.files[0].id + } + + test('should successfully get file citation in EndNote (XML) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.ENDNOTE) + + expect(typeof citation).toBe('string') + expect(citation.trimStart()).toMatch(/^<\?xml/) + }) + + test('should successfully get file citation in RIS (plain text) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.RIS) + + expect(typeof citation).toBe('string') + // RIS records use TY (type) and ER (end of record) tags + expect(citation).toMatch(/TY\s+-/) + expect(citation).toMatch(/ER\s+-/) + }) + + test('should successfully get file citation in BibTeX (plain text) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.BIBTEX) + + expect(typeof citation).toBe('string') + // BibTeX entries start with @{ + expect(citation.trimStart()).toMatch(/^@\w+\{/) + }) + + test('should successfully get file citation in CSL (JSON) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.CSL) + + expect(typeof citation).toBe('string') + const parsed = JSON.parse(citation) + expect(typeof parsed).toBe('object') + expect(parsed).not.toBeNull() + }) + + test('should successfully get file citation in Internal (HTML) format', async () => { + const fileId = await getTestFileId() + + const citation = await getFileCitationByFormat.execute(fileId, FileCitationFormat.INTERNAL) + + expect(typeof citation).toBe('string') + // Internal HTML format includes anchor tags linking to the dataset + expect(citation).toMatch(/ { + const nonExistentFileId = 5 + + await expect( + getFileCitationByFormat.execute(nonExistentFileId, FileCitationFormat.BIBTEX) + ).rejects.toThrow(ReadError) + }) +}) diff --git a/test/integration/files/FilesRepository.test.ts b/test/integration/files/FilesRepository.test.ts index 6e0c9bbc..f89b71a7 100644 --- a/test/integration/files/FilesRepository.test.ts +++ b/test/integration/files/FilesRepository.test.ts @@ -29,6 +29,7 @@ import { } from '../../../src/datasets' import { FileModel } from '../../../src/files/domain/models/FileModel' import { FileCounts } from '../../../src/files/domain/models/FileCounts' +import { FileCitationFormat } from '../../../src/files/domain/models/FileCitationFormat' import { FileDownloadSizeMode, WriteError } from '../../../src' import { deaccessionDatasetViaApi, @@ -656,6 +657,55 @@ describe('FilesRepository', () => { }) }) + describe('getFileCitationByFormat', () => { + test('should return EndNote citation as XML', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.ENDNOTE) + + expect(typeof citation).toBe('string') + expect(citation.trimStart()).toMatch(/^<\?xml/) + }) + + test('should return RIS citation as plain text', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.RIS) + + expect(typeof citation).toBe('string') + // RIS records use TY (type) and ER (end of record) tags + expect(citation).toMatch(/TY\s+-/) + expect(citation).toMatch(/ER\s+-/) + }) + + test('should return BibTeX citation as plain text', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.BIBTEX) + + expect(typeof citation).toBe('string') + // BibTeX entries start with @{ + expect(citation.trimStart()).toMatch(/^@\w+\{/) + }) + + test('should return CSL citation as JSON', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.CSL) + + expect(typeof citation).toBe('string') + const parsed = JSON.parse(citation) + expect(typeof parsed).toBe('object') + expect(parsed).not.toBeNull() + }) + + test('should return Internal citation as HTML', async () => { + const citation = await sut.getFileCitationByFormat(testFileId, FileCitationFormat.INTERNAL) + + expect(typeof citation).toBe('string') + // Internal HTML format includes anchor tags linking to the dataset + expect(citation).toMatch(/ { + await expect( + sut.getFileCitationByFormat(nonExistentFiledId, FileCitationFormat.BIBTEX) + ).rejects.toThrow(ReadError) + }) + }) + describe('getFileUploadDestination', () => { const testCollectionAlias = 'getFileUploadDestinationsTestCollection' let testDataset2Ids: CreatedDatasetIdentifiers diff --git a/test/unit/files/FilesRepository.test.ts b/test/unit/files/FilesRepository.test.ts index 4a9df202..ddbb8ea0 100644 --- a/test/unit/files/FilesRepository.test.ts +++ b/test/unit/files/FilesRepository.test.ts @@ -30,6 +30,7 @@ import { createFileCountsPayload } from '../../testHelpers/files/fileCountsHelper' import { createFilesTotalDownloadSizePayload } from '../../testHelpers/files/filesTotalDownloadSizeHelper' +import { FileCitationFormat } from '../../../src/files/domain/models/FileCitationFormat' import { FileDownloadSizeMode, WriteError } from '../../../src' import { createMultipartFileUploadDestinationModel, @@ -1164,6 +1165,84 @@ describe('FilesRepository', () => { }) }) + describe('getFileCitationByFormat', () => { + test.each([ + { + format: FileCitationFormat.ENDNOTE, + apiValue: 'EndNote', + contentType: 'XML', + responseData: '', + expected: '' + }, + { + format: FileCitationFormat.RIS, + apiValue: 'RIS', + contentType: 'plain text', + responseData: 'TY - DATA\nT1 - Test\nER - ', + expected: 'TY - DATA\nT1 - Test\nER - ' + }, + { + format: FileCitationFormat.BIBTEX, + apiValue: 'BibTeX', + contentType: 'plain text', + responseData: '@article{test}', + expected: '@article{test}' + }, + { + format: FileCitationFormat.CSL, + apiValue: 'CSL', + contentType: 'JSON', + // axios auto-parses JSON responses to objects; the repository stringifies them back. + responseData: [{ id: 'doi:10.5072/FK2/TEST', type: 'dataset' }], + expected: JSON.stringify([{ id: 'doi:10.5072/FK2/TEST', type: 'dataset' }]) + }, + { + format: FileCitationFormat.INTERNAL, + apiValue: 'Internal', + contentType: 'HTML', + responseData: 'Test Dataset', + expected: 'Test Dataset' + } + ])( + 'should call /citation/$apiValue and return $contentType citation for $apiValue format', + async ({ format, apiValue, responseData, expected }) => { + jest.spyOn(axios, 'get').mockResolvedValue({ data: responseData }) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/access/datafile/${testFile.id}/citation/${apiValue}` + + const actual = await sut.getFileCitationByFormat(testFile.id, format) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_API_KEY + ) + expect(actual).toEqual(expected) + } + ) + + test('should authenticate via session cookie when configured', async () => { + const testCitation = '@article{test}' + jest.spyOn(axios, 'get').mockResolvedValue({ data: testCitation }) + const expectedApiEndpoint = `${TestConstants.TEST_API_URL}/access/datafile/${testFile.id}/citation/BibTeX` + + ApiConfig.init(TestConstants.TEST_API_URL, DataverseApiAuthMechanism.SESSION_COOKIE) + const actual = await sut.getFileCitationByFormat(testFile.id, FileCitationFormat.BIBTEX) + + expect(axios.get).toHaveBeenCalledWith( + expectedApiEndpoint, + TestConstants.TEST_EXPECTED_AUTHENTICATED_REQUEST_CONFIG_SESSION_COOKIE + ) + expect(actual).toEqual(testCitation) + }) + + test('should return error on repository read error', async () => { + jest.spyOn(axios, 'get').mockRejectedValue(TestConstants.TEST_ERROR_RESPONSE) + + await expect( + sut.getFileCitationByFormat(testFile.id, FileCitationFormat.BIBTEX) + ).rejects.toThrow(ReadError) + }) + }) + describe('deleteFile', () => { describe('by numeric id', () => { test('should return undefined on success', async () => { diff --git a/test/unit/files/GetFileCitationByFormat.test.ts b/test/unit/files/GetFileCitationByFormat.test.ts new file mode 100644 index 00000000..d5bd8772 --- /dev/null +++ b/test/unit/files/GetFileCitationByFormat.test.ts @@ -0,0 +1,58 @@ +import { ReadError } from '../../../src' +import { FileCitationFormat } from '../../../src/files/domain/models/FileCitationFormat' +import { IFilesRepository } from '../../../src/files/domain/repositories/IFilesRepository' +import { GetFileCitationByFormat } from '../../../src/files/domain/useCases/GetFileCitationByFormat' + +describe('execute', () => { + const testId = 1 + + test.each([ + { + format: FileCitationFormat.ENDNOTE, + contentType: 'XML', + citation: '' + }, + { + format: FileCitationFormat.RIS, + contentType: 'plain text', + citation: 'TY - DATA\nT1 - Test\nER - ' + }, + { + format: FileCitationFormat.BIBTEX, + contentType: 'plain text', + citation: '@article{test}' + }, + { + format: FileCitationFormat.CSL, + contentType: 'JSON', + citation: JSON.stringify([{ id: 'doi:10.5072/FK2/TEST', type: 'dataset' }]) + }, + { + format: FileCitationFormat.INTERNAL, + contentType: 'HTML', + citation: 'Test Dataset' + } + ])( + 'should return file citation in $contentType when format is $format', + async ({ format, citation }) => { + const filesRepositoryStub = {} + filesRepositoryStub.getFileCitationByFormat = jest.fn().mockResolvedValue(citation) + + const sut = new GetFileCitationByFormat(filesRepositoryStub) + + const actual = await sut.execute(testId, format) + + expect(actual).toEqual(citation) + expect(filesRepositoryStub.getFileCitationByFormat).toHaveBeenCalledWith(testId, format) + } + ) + + test('should return error result on repository error', async () => { + const filesRepositoryStub = {} + filesRepositoryStub.getFileCitationByFormat = jest.fn().mockRejectedValue(new ReadError()) + + const sut = new GetFileCitationByFormat(filesRepositoryStub) + + await expect(sut.execute(testId, FileCitationFormat.BIBTEX)).rejects.toThrow(ReadError) + }) +}) From 7f435081996228afd1a7a6b95f75bc8d6f1ee64b Mon Sep 17 00:00:00 2001 From: Cheng Shi Date: Tue, 28 Apr 2026 15:17:38 -0400 Subject: [PATCH 2/2] fix: useCases.md --- docs/useCases.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/docs/useCases.md b/docs/useCases.md index fbc9f553..9e7e24eb 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -1718,20 +1718,15 @@ Returns the File citation in the requested citation export format. ##### Example call: ```typescript -import { - FileCitationFormat, - getFileCitationByFormat -} from '@iqss/dataverse-client-javascript' +import { FileCitationFormat, getFileCitationByFormat } from '@iqss/dataverse-client-javascript' /* ... */ const fileId = 3 -getFileCitationByFormat - .execute(fileId, FileCitationFormat.BIBTEX) - .then((citationText: string) => { - /* ... */ - }) +getFileCitationByFormat.execute(fileId, FileCitationFormat.BIBTEX).then((citationText: string) => { + /* ... */ +}) /* ... */ ```