Skip to content

Commit 7fa6e4d

Browse files
committed
Merge remote-tracking branch 'origin/develop' into add_metadata_block
2 parents b100126 + 5d88a09 commit 7fa6e4d

13 files changed

Lines changed: 398 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel
88

99
### Added
1010

11+
### Changed
12+
13+
### Fixed
14+
15+
### Removed
16+
17+
## [v2.2.0] -- 2026-04-24
18+
19+
### Added
20+
1121
- Datasets: Added `updateDatasetLicense` use case and repository method to support Dataverse endpoint `PUT /datasets/{id}/license`, for updating dataset license or custom terms.
1222
- Datasets: Added `getDatasetStorageDriver` use case and repository method to support Dataverse endpoint `GET /datasets/{identifier}/storageDriver`, for retrieving dataset storage driver configuration with properties: name, type, label, directUpload, directDownload, and uploadOutOfBand.
1323
- Datasets: Added `getDatasetUploadLimits` use case and repository method to support Dataverse endpoint `GET /datasets/{id}/uploadlimits`, for retrieving remaining storage upload quotas, if present.
@@ -32,6 +42,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel
3242
- Templates: Rename `CreateDatasetTemplateDTO` to `CreateTemplateDTO`.
3343
- Templates: Rename `createDatasetTemplate` repository method to `createTemplate`.
3444
- Templates: Rename `getDatasetTemplates` repository method to `getTemplatesByCollectionId`.
45+
- Collections: `updateCollection` now supports partial updates by accepting `Partial<CollectionDTO>`. Only explicitly provided fields are sent in update requests, aligning with Dataverse API semantics. Metadata blocks handling was adjusted to respect inheritance flags and avoid invalid field combinations.
3546

3647
### Fixed
3748

@@ -42,7 +53,7 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel
4253

4354
- Removed date fields validations in create and update dataset use cases, since validation is already handled in the backend and SPA frontend (other clients should perform client side validation also). This avoids duplicated logic and keeps the package focused on its core responsibility.
4455

45-
[Unreleased]: https://github.com/IQSS/dataverse-client-javascript/compare/v2.1.0...develop
56+
[Unreleased]: https://github.com/IQSS/dataverse-client-javascript/compare/v2.2.0...develop
4657

4758
---
4859

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@iqss/dataverse-client-javascript",
3-
"version": "2.1.0",
3+
"version": "2.2.0",
44
"description": "Dataverse API wrapper package for JavaScript/TypeScript-based applications",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",
Lines changed: 117 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
import { ApiConfig, DataverseApiAuthMechanism } from '../../../core/infra/repositories/ApiConfig'
2+
import { WriteError } from '../../../core/domain/repositories/WriteError'
3+
import { ApiConstants } from '../../../core/infra/repositories/ApiConstants'
14
import { ApiRepository } from '../../../core/infra/repositories/ApiRepository'
5+
import {
6+
buildRequestConfig,
7+
buildRequestUrl
8+
} from '../../../core/infra/repositories/apiConfigBuilders'
29
import { GuestbookResponseDTO } from '../../domain/dtos/GuestbookResponseDTO'
310
import { IAccessRepository } from '../../domain/repositories/IAccessRepository'
411

@@ -13,14 +20,7 @@ export class AccessRepository extends ApiRepository implements IAccessRepository
1320
const endpoint = this.buildApiEndpoint(`${this.accessResourceName}/datafile`, undefined, fileId)
1421
const queryParams = format ? { signed: true, format } : { signed: true }
1522

16-
return this.doPost(endpoint, guestbookResponse, queryParams)
17-
.then((response) => {
18-
const signedUrl = response.data.data.signedUrl
19-
return signedUrl
20-
})
21-
.catch((error) => {
22-
throw error
23-
})
23+
return await this.submitGuestbookDownload(endpoint, guestbookResponse, queryParams)
2424
}
2525

2626
public async submitGuestbookForDatafilesDownload(
@@ -30,21 +30,14 @@ export class AccessRepository extends ApiRepository implements IAccessRepository
3030
): Promise<string> {
3131
const queryParams = format ? { signed: true, format } : { signed: true }
3232

33-
return this.doPost(
33+
return await this.submitGuestbookDownload(
3434
this.buildApiEndpoint(
3535
this.accessResourceName,
3636
`datafiles/${Array.isArray(fileIds) ? fileIds.join(',') : fileIds}`
3737
),
3838
guestbookResponse,
3939
queryParams
4040
)
41-
.then((response) => {
42-
const signedUrl = response.data.data.signedUrl
43-
return signedUrl
44-
})
45-
.catch((error) => {
46-
throw error
47-
})
4841
}
4942

5043
public async submitGuestbookForDatasetDownload(
@@ -59,14 +52,7 @@ export class AccessRepository extends ApiRepository implements IAccessRepository
5952
)
6053
const queryParams = format ? { signed: true, format } : { signed: true }
6154

62-
return this.doPost(endpoint, guestbookResponse, queryParams)
63-
.then((response) => {
64-
const signedUrl = response.data.data.signedUrl
65-
return signedUrl
66-
})
67-
.catch((error) => {
68-
throw error
69-
})
55+
return await this.submitGuestbookDownload(endpoint, guestbookResponse, queryParams)
7056
}
7157

7258
public async submitGuestbookForDatasetVersionDownload(
@@ -82,13 +68,112 @@ export class AccessRepository extends ApiRepository implements IAccessRepository
8268
)
8369
const queryParams = format ? { signed: true, format } : { signed: true }
8470

85-
return this.doPost(endpoint, guestbookResponse, queryParams)
86-
.then((response) => {
87-
const signedUrl = response.data.data.signedUrl
88-
return signedUrl
89-
})
90-
.catch((error) => {
91-
throw error
92-
})
71+
return await this.submitGuestbookDownload(endpoint, guestbookResponse, queryParams)
72+
}
73+
74+
private async submitGuestbookDownload(
75+
apiEndpoint: string,
76+
guestbookResponse: GuestbookResponseDTO,
77+
queryParams: object
78+
): Promise<string> {
79+
const requestConfig = buildRequestConfig(
80+
true,
81+
queryParams,
82+
ApiConstants.CONTENT_TYPE_APPLICATION_JSON
83+
)
84+
const response = await fetch(
85+
this.buildUrlWithQueryParams(buildRequestUrl(apiEndpoint), queryParams),
86+
{
87+
method: 'POST',
88+
headers: this.buildFetchHeaders(requestConfig.headers),
89+
credentials: this.getFetchCredentials(requestConfig.withCredentials),
90+
body: JSON.stringify(guestbookResponse)
91+
}
92+
).catch((error) => {
93+
throw new WriteError(error instanceof Error ? error.message : String(error))
94+
})
95+
96+
const responseData = await this.parseResponseBody(response)
97+
98+
if (!response.ok) {
99+
throw new WriteError(this.buildFetchErrorMessage(response.status, responseData))
100+
}
101+
102+
return this.getSignedUrlOrThrow(responseData)
103+
}
104+
105+
private getFetchCredentials(withCredentials?: boolean): RequestCredentials | undefined {
106+
if (ApiConfig.dataverseApiAuthMechanism === DataverseApiAuthMechanism.BEARER_TOKEN) {
107+
return 'omit'
108+
}
109+
110+
if (withCredentials) {
111+
return 'include'
112+
}
113+
114+
return undefined
115+
}
116+
117+
private buildUrlWithQueryParams(requestUrl: string, queryParams: object): string {
118+
const url = new URL(requestUrl)
119+
120+
Object.entries(queryParams).forEach(([key, value]) => {
121+
if (value !== undefined && value !== null) {
122+
url.searchParams.append(key, String(value))
123+
}
124+
})
125+
126+
return url.toString()
127+
}
128+
129+
private buildFetchHeaders(headers?: Record<string, unknown>): Record<string, string> {
130+
const fetchHeaders: Record<string, string> = {}
131+
132+
if (!headers) {
133+
return fetchHeaders
134+
}
135+
136+
Object.entries(headers).forEach(([key, value]) => {
137+
if (value !== undefined) {
138+
fetchHeaders[key] = String(value)
139+
}
140+
})
141+
142+
return fetchHeaders
143+
}
144+
145+
private async parseResponseBody(response: Response): Promise<any> {
146+
const contentType = response.headers.get('content-type') ?? ''
147+
148+
if (contentType.includes('application/json')) {
149+
return await response.json()
150+
}
151+
152+
const responseText = await response.text()
153+
154+
try {
155+
return JSON.parse(responseText)
156+
} catch {
157+
return responseText
158+
}
159+
}
160+
161+
private buildFetchErrorMessage(status: number, responseData: any): string {
162+
const message =
163+
typeof responseData === 'string'
164+
? responseData
165+
: responseData?.message || responseData?.data?.message || 'unknown error'
166+
167+
return `[${status}] ${message}`
168+
}
169+
170+
private getSignedUrlOrThrow(responseData: any): string {
171+
const signedUrl = responseData?.data?.signedUrl
172+
173+
if (typeof signedUrl !== 'string' || signedUrl.length === 0) {
174+
throw new WriteError('Missing signedUrl in access download response.')
175+
}
176+
177+
return signedUrl
93178
}
94179
}

src/collections/domain/repositories/ICollectionsRepository.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface ICollectionsRepository {
4444
): Promise<MyDataCollectionItemSubset>
4545
updateCollection(
4646
collectionIdOrAlias: number | string,
47-
updatedCollection: CollectionDTO
47+
updatedCollection: Partial<CollectionDTO>
4848
): Promise<void>
4949
getCollectionFeaturedItems(collectionIdOrAlias: number | string): Promise<FeaturedItem[]>
5050
updateCollectionFeaturedItems(

src/collections/domain/useCases/UpdateCollection.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export class UpdateCollection implements UseCase<void> {
1919
*/
2020
async execute(
2121
collectionIdOrAlias: number | string,
22-
updatedCollection: CollectionDTO
22+
updatedCollection: Partial<CollectionDTO>
2323
): Promise<void> {
2424
return await this.collectionsRepository.updateCollection(collectionIdOrAlias, updatedCollection)
2525
}

src/collections/infra/repositories/CollectionsRepository.ts

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,9 @@ export class CollectionsRepository extends ApiRepository implements ICollections
215215

216216
public async updateCollection(
217217
collectionIdOrAlias: string | number,
218-
updatedCollection: CollectionDTO
218+
updatedCollection: Partial<CollectionDTO>
219219
): Promise<void> {
220-
const requestBody = this.createCreateOrUpdateRequestBody(updatedCollection)
220+
const requestBody = this.createUpdateRequestBody(updatedCollection)
221221

222222
return this.doPut(`/${this.collectionsResourceName}/${collectionIdOrAlias}`, requestBody)
223223
.then(() => undefined)
@@ -332,6 +332,86 @@ export class CollectionsRepository extends ApiRepository implements ICollections
332332
}
333333
}
334334

335+
private createUpdateRequestBody(
336+
collectionDTO: Partial<CollectionDTO>
337+
): Partial<NewCollectionRequestPayload> {
338+
const dataverseContacts: NewCollectionContactRequestPayload[] | undefined =
339+
collectionDTO.contacts?.map((contact) => ({
340+
contactEmail: contact
341+
}))
342+
const inputLevelsRequestBody: NewCollectionInputLevelRequestPayload[] | undefined =
343+
collectionDTO.inputLevels?.map((inputLevel) => ({
344+
datasetFieldTypeName: inputLevel.datasetFieldName,
345+
include: inputLevel.include,
346+
required: inputLevel.required
347+
}))
348+
let metadataBlocksRequestBody: Partial<NewCollectionMetadataBlocksRequestPayload> | undefined
349+
350+
const hasMetadataBlocksData =
351+
collectionDTO.metadataBlockNames !== undefined ||
352+
collectionDTO.facetIds !== undefined ||
353+
collectionDTO.inputLevels !== undefined ||
354+
collectionDTO.inheritMetadataBlocksFromParent !== undefined ||
355+
collectionDTO.inheritFacetsFromParent !== undefined
356+
357+
if (hasMetadataBlocksData) {
358+
metadataBlocksRequestBody = {}
359+
if (collectionDTO.inheritMetadataBlocksFromParent !== true) {
360+
if (collectionDTO.metadataBlockNames !== undefined) {
361+
metadataBlocksRequestBody.metadataBlockNames = collectionDTO.metadataBlockNames
362+
}
363+
if (inputLevelsRequestBody !== undefined) {
364+
metadataBlocksRequestBody.inputLevels = inputLevelsRequestBody
365+
}
366+
}
367+
if (collectionDTO.inheritFacetsFromParent !== true) {
368+
if (collectionDTO.facetIds !== undefined) {
369+
metadataBlocksRequestBody.facetIds = collectionDTO.facetIds
370+
}
371+
}
372+
if (collectionDTO.inheritMetadataBlocksFromParent !== undefined) {
373+
metadataBlocksRequestBody.inheritMetadataBlocksFromParent =
374+
collectionDTO.inheritMetadataBlocksFromParent
375+
}
376+
if (collectionDTO.inheritFacetsFromParent !== undefined) {
377+
metadataBlocksRequestBody.inheritFacetsFromParent = collectionDTO.inheritFacetsFromParent
378+
}
379+
}
380+
381+
// Build the final request body, only including defined fields
382+
const requestBody: Partial<NewCollectionRequestPayload> = {}
383+
384+
if (collectionDTO.alias !== undefined) {
385+
requestBody.alias = collectionDTO.alias
386+
}
387+
388+
if (collectionDTO.name !== undefined) {
389+
requestBody.name = collectionDTO.name
390+
}
391+
392+
if (dataverseContacts !== undefined) {
393+
requestBody.dataverseContacts = dataverseContacts
394+
}
395+
396+
if (collectionDTO.type !== undefined) {
397+
requestBody.dataverseType = collectionDTO.type
398+
}
399+
400+
if (collectionDTO.description !== undefined) {
401+
requestBody.description = collectionDTO.description
402+
}
403+
404+
if (collectionDTO.affiliation !== undefined) {
405+
requestBody.affiliation = collectionDTO.affiliation
406+
}
407+
408+
if (metadataBlocksRequestBody !== undefined) {
409+
requestBody.metadataBlocks = metadataBlocksRequestBody
410+
}
411+
412+
return requestBody
413+
}
414+
335415
private applyCollectionSearchCriteriaToQueryParams(
336416
queryParams: URLSearchParams,
337417
collectionSearchCriteria: CollectionSearchCriteria

src/core/infra/repositories/ApiConfig.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ export class ApiConfig {
33
static dataverseApiAuthMechanism: DataverseApiAuthMechanism
44
static dataverseApiKey?: string
55
static bearerTokenLocalStorageKey?: string
6+
static bearerTokenGetFunction?: () => string | null
67

78
static init(
89
dataverseApiUrl: string,
910
dataverseApiAuthMechanism: DataverseApiAuthMechanism,
1011
dataverseApiKey?: string,
11-
bearerTokenLocalStorageKey?: string
12+
bearerTokenLocalStorageKey?: string,
13+
bearerTokenGetFunction?: () => string | null
1214
) {
1315
this.dataverseApiUrl = dataverseApiUrl
1416
this.dataverseApiAuthMechanism = dataverseApiAuthMechanism
1517
this.dataverseApiKey = dataverseApiKey
1618
this.bearerTokenLocalStorageKey = bearerTokenLocalStorageKey
19+
this.bearerTokenGetFunction = bearerTokenGetFunction
1720
}
1821
}
1922

0 commit comments

Comments
 (0)