diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6861af..ad4a912c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel - Guestbooks: Added optional `includeStats` support to `getGuestbooksByCollectionId`, returning `usageCount` and `responseCount` when requested. - Files: Added `getFileCitationByFormat` use case, repository method, and `FileCitationFormat` enum to support Dataverse file citation exports in `EndNote`, `RIS`, `BibTeX`, `CSL`, and `Internal` formats. - Datasets: Added `getDatasetReviews` use case and repository method to support Dataverse endpoint `GET /datasets/{identifier}/reviews`, for retrieving review datasets associated with a dataset by persistent id or numeric id. +- Datasets: Added `exportDatasetMetadata` use case, repository method, and `ExportedDatasetMetadata` response type to support exporting dataset metadata by numeric id or persistent id through Dataverse endpoint `GET /datasets/export`. - Collections: Added `allowedDatasetTypes` field to the [Collection](./src/collections/domain/models/Collection.ts) model. This field is optional and only populated the feature is enabled on the installation and configured on the collection. - Collections: Added theme information when retrieving a collection using `getCollection`. diff --git a/docs/useCases.md b/docs/useCases.md index 969a6240..2df0b987 100644 --- a/docs/useCases.md +++ b/docs/useCases.md @@ -46,6 +46,8 @@ The different use cases currently available in the package are classified below, - [Get a Dataset](#get-a-dataset) - [Get Dataset By Private URL Token](#get-dataset-by-private-url-token) - [Get Dataset Citation Text](#get-dataset-citation-text) + - [Get Dataset Citation In Other Formats](#get-dataset-citation-in-other-formats) + - [Export Dataset Metadata](#export-dataset-metadata) - [Get Dataset Citation Text By Private URL Token](#get-dataset-citation-text-by-private-url-token) - [Get Dataset Locks](#get-dataset-locks) - [Get Dataset Summary Field Names](#get-dataset-summary-field-names) @@ -1053,6 +1055,37 @@ The `datasetId` parameter can be a string, for persistent identifiers, or a numb There is an optional third parameter called `includeDeaccessioned`, which indicates whether to consider deaccessioned versions or not in the dataset search. If not set, the default value is `false`. +#### Export Dataset Metadata + +Exports dataset metadata in a specified metadata export format. + +##### Example call: + +```typescript +import { + exportDatasetMetadata, + DatasetNotNumberedVersion, + ExportedDatasetMetadata +} from '@iqss/dataverse-client-javascript' + +/* ... */ + +const datasetId = 'doi:10.77777/FK2/AAAAAA' +const exporter = 'ddi' + +exportDatasetMetadata + .execute(datasetId, exporter, DatasetNotNumberedVersion.DRAFT) + .then((metadata: ExportedDatasetMetadata) => { + /* ... */ + }) + +/* ... */ +``` + +_See [use case](../src/datasets/domain/useCases/ExportDatasetMetadata.ts) implementation_. + +The `datasetId` parameter can be a string for persistent identifiers or a number for numeric identifiers. The optional `version` parameter accepts `DatasetNotNumberedVersion.LATEST_PUBLISHED` or `DatasetNotNumberedVersion.DRAFT`. If not set, Dataverse defaults to `DatasetNotNumberedVersion.LATEST_PUBLISHED`. Draft exports require configured authentication with access to the draft. + #### Get Dataset Citation Text By Private URL Token Returns the Dataset citation text, given an associated Private URL Token. diff --git a/src/datasets/domain/models/ExportedDatasetMetadata.ts b/src/datasets/domain/models/ExportedDatasetMetadata.ts new file mode 100644 index 00000000..e6d259e7 --- /dev/null +++ b/src/datasets/domain/models/ExportedDatasetMetadata.ts @@ -0,0 +1,4 @@ +export type ExportedDatasetMetadata = { + content: string + contentType: string +} diff --git a/src/datasets/domain/repositories/IDatasetsRepository.ts b/src/datasets/domain/repositories/IDatasetsRepository.ts index b3a20d73..a50ad2ff 100644 --- a/src/datasets/domain/repositories/IDatasetsRepository.ts +++ b/src/datasets/domain/repositories/IDatasetsRepository.ts @@ -19,6 +19,8 @@ import { DatasetTypeDTO } from '../dtos/DatasetTypeDTO' import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { DatasetUploadLimits } from '../models/DatasetUploadLimits' import { DatasetReview } from '../models/DatasetReview' +import { ExportedDatasetMetadata } from '../models/ExportedDatasetMetadata' +import { DatasetNotNumberedVersion } from '../models/DatasetNotNumberedVersion' export interface IDatasetsRepository { getDataset( @@ -86,6 +88,11 @@ export interface IDatasetsRepository { format: CitationFormat, includeDeaccessioned?: boolean ): Promise + exportDatasetMetadata( + datasetId: number | string, + exporter: string, + version?: DatasetNotNumberedVersion.LATEST_PUBLISHED | DatasetNotNumberedVersion.DRAFT + ): Promise getDatasetAvailableDatasetTypes(): Promise getDatasetAvailableDatasetType(datasetTypeId: number | string): Promise addDatasetType(datasetType: DatasetTypeDTO): Promise diff --git a/src/datasets/domain/useCases/ExportDatasetMetadata.ts b/src/datasets/domain/useCases/ExportDatasetMetadata.ts new file mode 100644 index 00000000..15fd8b02 --- /dev/null +++ b/src/datasets/domain/useCases/ExportDatasetMetadata.ts @@ -0,0 +1,28 @@ +import { UseCase } from '../../../core/domain/useCases/UseCase' +import { DatasetNotNumberedVersion } from '../models/DatasetNotNumberedVersion' +import { ExportedDatasetMetadata } from '../models/ExportedDatasetMetadata' +import { IDatasetsRepository } from '../repositories/IDatasetsRepository' + +export class ExportDatasetMetadata implements UseCase { + private datasetsRepository: IDatasetsRepository + + constructor(datasetsRepository: IDatasetsRepository) { + this.datasetsRepository = datasetsRepository + } + + /** + * Exports dataset metadata in the specified metadata export format. + * + * @param {number | string} datasetId - The dataset identifier, which can be a string for persistent identifiers or a number for numeric identifiers. + * @param {string} exporter - The metadata exporter format name. + * @param {DatasetNotNumberedVersion.LATEST_PUBLISHED | DatasetNotNumberedVersion.DRAFT} [version] - The dataset version to export. If omitted, Dataverse defaults to :latest-published. + * @returns {Promise} The exported metadata content and content type. + */ + async execute( + datasetId: number | string, + exporter: string, + version?: DatasetNotNumberedVersion.LATEST_PUBLISHED | DatasetNotNumberedVersion.DRAFT + ): Promise { + return await this.datasetsRepository.exportDatasetMetadata(datasetId, exporter, version) + } +} diff --git a/src/datasets/index.ts b/src/datasets/index.ts index 37d09bc1..de7a32b4 100644 --- a/src/datasets/index.ts +++ b/src/datasets/index.ts @@ -36,6 +36,7 @@ import { UpdateDatasetLicense } from './domain/useCases/UpdateDatasetLicense' import { GetDatasetStorageDriver } from './domain/useCases/GetDatasetStorageDriver' import { GetDatasetUploadLimits } from './domain/useCases/GetDatasetUploadLimits' import { GetDatasetReviews } from './domain/useCases/GetDatasetReviews' +import { ExportDatasetMetadata } from './domain/useCases/ExportDatasetMetadata' const datasetsRepository = new DatasetsRepository() @@ -88,6 +89,7 @@ const updateDatasetLicense = new UpdateDatasetLicense(datasetsRepository) const getDatasetStorageDriver = new GetDatasetStorageDriver(datasetsRepository) const getDatasetUploadLimits = new GetDatasetUploadLimits(datasetsRepository) const getDatasetReviews = new GetDatasetReviews(datasetsRepository) +const exportDatasetMetadata = new ExportDatasetMetadata(datasetsRepository) export { getDataset, @@ -121,9 +123,11 @@ export { updateDatasetLicense, getDatasetStorageDriver, getDatasetUploadLimits, - getDatasetReviews + getDatasetReviews, + exportDatasetMetadata } export { DatasetNotNumberedVersion } from './domain/models/DatasetNotNumberedVersion' +export { ExportedDatasetMetadata } from './domain/models/ExportedDatasetMetadata' export { DatasetUserPermissions } from './domain/models/DatasetUserPermissions' export { DatasetLock, DatasetLockType } from './domain/models/DatasetLock' export { diff --git a/src/datasets/infra/repositories/DatasetsRepository.ts b/src/datasets/infra/repositories/DatasetsRepository.ts index e004aa8d..1eba3fa4 100644 --- a/src/datasets/infra/repositories/DatasetsRepository.ts +++ b/src/datasets/infra/repositories/DatasetsRepository.ts @@ -26,6 +26,7 @@ import { transformDatasetLinkedCollectionsResponseToDatasetLinkedCollection } fr import { FormattedCitation } from '../../domain/models/FormattedCitation' import { DatasetType } from '../../domain/models/DatasetType' import { TermsOfAccess } from '../../domain/models/Dataset' +import { DatasetNotNumberedVersion } from '../../domain/models/DatasetNotNumberedVersion' import { transformTermsOfAccessToUpdatePayload } from './transformers/termsOfAccessTransformers' import { DatasetLicenseUpdateRequest } from '../../domain/dtos/DatasetLicenseUpdateRequest' import { DatasetTypeDTO } from '../../domain/dtos/DatasetTypeDTO' @@ -33,6 +34,7 @@ import { StorageDriver } from '../../../core/domain/models/StorageDriver' import { DatasetUploadLimits } from '../../domain/models/DatasetUploadLimits' import { DatasetReview } from '../../domain/models/DatasetReview' import { transformDatasetReviewsResponseToDatasetReviews } from './transformers/datasetReviewTransformers' +import { ExportedDatasetMetadata } from '../../domain/models/ExportedDatasetMetadata' export interface GetAllDatasetPreviewsQueryParams { per_page?: number @@ -133,6 +135,39 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi } } + public async exportDatasetMetadata( + datasetId: number | string, + exporter: string, + version?: DatasetNotNumberedVersion.LATEST_PUBLISHED | DatasetNotNumberedVersion.DRAFT + ): Promise { + const persistentId = + typeof datasetId === 'number' + ? ( + await this.getDataset( + datasetId, + version ?? DatasetNotNumberedVersion.LATEST_PUBLISHED, + false, + false + ) + ).persistentId + : datasetId + + const response = await this.doGet('/datasets/export', true, { + exporter, + persistentId, + ...(version && { version }) + }) + + const contentType = String(response.headers['content-type'] ?? '') + const content = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + + return { + content, + contentType + } + } + public async getPrivateUrlDatasetCitation(token: string): Promise { return this.doGet( this.buildApiEndpoint(this.datasetsResourceName, `privateUrlDatasetVersion/${token}/citation`) diff --git a/test/functional/collections/UnlinkCollection.test.ts b/test/functional/collections/UnlinkCollection.test.ts index 0b20b455..badf54ce 100644 --- a/test/functional/collections/UnlinkCollection.test.ts +++ b/test/functional/collections/UnlinkCollection.test.ts @@ -5,6 +5,7 @@ import { linkCollection, deleteCollection, getCollectionItems, + getCollectionLinks, unlinkCollection } from '../../../src' import { TestConstants } from '../../testHelpers/TestConstants' @@ -43,12 +44,14 @@ describe('execute', () => { // Verify that the collections are linked const collectionItemSubset = await getCollectionItems.execute(firstCollectionAlias) expect(collectionItemSubset.items.length).toBe(1) + const collectionLinksBefore = await getCollectionLinks.execute(firstCollectionId) + expect(collectionLinksBefore.linkedCollections.map((collection) => collection.alias)).toContain( + secondCollectionAlias + ) await unlinkCollection.execute(secondCollectionAlias, firstCollectionAlias) - // Wait for the unlinking to be processed by Solr - await new Promise((resolve) => setTimeout(resolve, 5000)) - const collectionItemSubset2 = await getCollectionItems.execute(firstCollectionAlias) - expect(collectionItemSubset2.items.length).toBe(0) + const collectionLinksAfter = await getCollectionLinks.execute(firstCollectionId) + expect(collectionLinksAfter.linkedCollections).toStrictEqual([]) }) test('should throw an error when unlinking a non-existent collection', async () => { diff --git a/test/integration/collections/CollectionsRepository.test.ts b/test/integration/collections/CollectionsRepository.test.ts index 2f563403..72a46165 100644 --- a/test/integration/collections/CollectionsRepository.test.ts +++ b/test/integration/collections/CollectionsRepository.test.ts @@ -2143,12 +2143,16 @@ describe('CollectionsRepository', () => { const firstCollection = await sut.getCollection(firstCollectionAlias) const secondCollection = await sut.getCollection(secondCollectionAlias) + const collectionLinksBefore = await sut.getCollectionLinks(firstCollection.id) + expect( + collectionLinksBefore.linkedCollections.map((collection) => collection.alias) + ).toContain(secondCollection.alias) + await sut.unlinkCollection(secondCollection.id, firstCollection.id) - await new Promise((res) => setTimeout(res, 2000)) await sut.getCollection(secondCollectionAlias) - const collectionItemSubset = await sut.getCollectionItems(firstCollection.alias) - expect(collectionItemSubset.items).toStrictEqual([]) + const collectionLinksAfter = await sut.getCollectionLinks(firstCollection.id) + expect(collectionLinksAfter.linkedCollections).toStrictEqual([]) }) test('should throw error when unlinking a non-existent collection', async () => { diff --git a/test/integration/datasets/DatasetsRepository.test.ts b/test/integration/datasets/DatasetsRepository.test.ts index 3edf2315..6a67299e 100644 --- a/test/integration/datasets/DatasetsRepository.test.ts +++ b/test/integration/datasets/DatasetsRepository.test.ts @@ -772,6 +772,43 @@ describe('DatasetsRepository', () => { }) }) + describe('exportDatasetMetadata', () => { + let draftDatasetIds: CreatedDatasetIdentifiers + let publishedDatasetIds: CreatedDatasetIdentifiers + + beforeAll(async () => { + draftDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + publishedDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO) + await publishDatasetViaApi(publishedDatasetIds.numericId) + await waitForNoLocks(publishedDatasetIds.numericId, 10) + }) + + afterAll(async () => { + await deleteUnpublishedDatasetViaApi(draftDatasetIds.numericId) + await deletePublishedDatasetViaApi(publishedDatasetIds.persistentId) + }) + + test('should export latest published dataset metadata in DDI format by default', async () => { + const metadata = await sut.exportDatasetMetadata(publishedDatasetIds.persistentId, 'ddi') + + expect(typeof metadata.content).toBe('string') + expect(metadata.content.length).toBeGreaterThan(0) + expect(metadata.contentType).toMatch(/xml/) + }) + + test('should export draft dataset metadata in DDI format', async () => { + const metadata = await sut.exportDatasetMetadata( + draftDatasetIds.numericId, + 'ddi', + DatasetNotNumberedVersion.DRAFT + ) + + expect(typeof metadata.content).toBe('string') + expect(metadata.content.length).toBeGreaterThan(0) + expect(metadata.contentType).toMatch(/xml/) + }) + }) + describe('getDatasetVersionDiff', () => { let testDatasetIds: CreatedDatasetIdentifiers diff --git a/test/unit/datasets/ExportDatasetMetadata.test.ts b/test/unit/datasets/ExportDatasetMetadata.test.ts new file mode 100644 index 00000000..6791859f --- /dev/null +++ b/test/unit/datasets/ExportDatasetMetadata.test.ts @@ -0,0 +1,42 @@ +import { ExportDatasetMetadata } from '../../../src/datasets/domain/useCases/ExportDatasetMetadata' +import { IDatasetsRepository } from '../../../src/datasets/domain/repositories/IDatasetsRepository' +import { ReadError } from '../../../src/core/domain/repositories/ReadError' +import { ExportedDatasetMetadata } from '../../../src/datasets/domain/models/ExportedDatasetMetadata' +import { DatasetNotNumberedVersion } from '../../../src/datasets/domain/models/DatasetNotNumberedVersion' + +describe('ExportDatasetMetadata.execute', () => { + const testDatasetId = 1 + const testExporter = 'ddi' + + test('should return exported dataset metadata on repository success', async () => { + const expectedMetadata: ExportedDatasetMetadata = { + content: '', + contentType: 'application/xml' + } + + const datasetsRepositoryStub: IDatasetsRepository = { + exportDatasetMetadata: jest.fn().mockResolvedValue(expectedMetadata) + } as unknown as IDatasetsRepository + + const sut = new ExportDatasetMetadata(datasetsRepositoryStub) + + const actual = await sut.execute(testDatasetId, testExporter, DatasetNotNumberedVersion.DRAFT) + + expect(actual).toEqual(expectedMetadata) + expect(datasetsRepositoryStub.exportDatasetMetadata).toHaveBeenCalledWith( + testDatasetId, + testExporter, + DatasetNotNumberedVersion.DRAFT + ) + }) + + test('should throw ReadError on repository failure', async () => { + const datasetsRepositoryStub: IDatasetsRepository = { + exportDatasetMetadata: jest.fn().mockRejectedValue(new ReadError()) + } as unknown as IDatasetsRepository + + const sut = new ExportDatasetMetadata(datasetsRepositoryStub) + + await expect(sut.execute(testDatasetId, testExporter)).rejects.toThrow(ReadError) + }) +})