dataconv-client-sdk-ts 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,567 @@
1
+ import axios, { type AxiosInstance } from 'axios';
2
+ import { DEFAULT_SECTOR, DEFAULT_UPLOAD_BODY } from './client/constants.js';
3
+ import {
4
+ buildUrl,
5
+ createUuid,
6
+ headerValue,
7
+ headersToObject,
8
+ isFormData,
9
+ normalizeSearchParams,
10
+ normalizeSourceFormat,
11
+ requireText,
12
+ resolveConfigSoftwareId,
13
+ resolveConfigTenantId,
14
+ resolveJurisdiction,
15
+ resolveResourceType,
16
+ resolveSector,
17
+ resolveTenantId,
18
+ sleep
19
+ } from './client/helpers.js';
20
+ import { buildAttachment, buildEnvelope, buildMultipartFormData, buildUploadExtra } from './client/message.js';
21
+ import type {
22
+ DataConvBatchOptions,
23
+ ConversionResultEntry,
24
+ ConvertedBundleResource,
25
+ CreateTenantConfigOptions,
26
+ DataConvClientConfig,
27
+ DataConvConversionPollOptions,
28
+ DataConvCreateResult,
29
+ DataConvCrypto,
30
+ DataConvDidCommAttachment,
31
+ DataConvDidCommResponse,
32
+ DataConvMultipartUploadOptions,
33
+ DataConvOperationOutcome,
34
+ DataConvPatchOptions,
35
+ DataConvPatchResponse,
36
+ DataConvSearchBundle,
37
+ DataConvSearchOptions,
38
+ DataConvTenantConfigPollOptions,
39
+ DataConvUploadDidCommOptions,
40
+ DataConvUploadResult,
41
+ SourceFormat,
42
+ TenantAdapterConfigEntry,
43
+ TenantAdapterConfigResource
44
+ } from './types.js';
45
+
46
+ export class DataConvClient {
47
+ private readonly httpClient?: AxiosInstance;
48
+ private readonly fetchFn?: typeof fetch;
49
+ private readonly cryptoApi?: DataConvCrypto;
50
+ private readonly baseUrl: string;
51
+ private readonly retryTimes: number;
52
+ private readonly retryDelayMs: number;
53
+ private readonly defaultExpSeconds: number;
54
+
55
+ private idToken?: string;
56
+ private vpToken?: string;
57
+ private lastTenantConfigResponse?: DataConvDidCommResponse<TenantAdapterConfigResource>;
58
+ private lastConversionResponse?: DataConvDidCommResponse<ConvertedBundleResource>;
59
+
60
+ constructor(private readonly config: DataConvClientConfig) {
61
+ this.baseUrl = config.baseUrl || process.env.DATACONV_BASE_URL || 'http://localhost:8080';
62
+ this.fetchFn = config.fetch ?? (typeof fetch !== 'undefined' ? fetch : undefined);
63
+ this.cryptoApi = config.crypto ?? (globalThis as typeof globalThis & { crypto?: DataConvCrypto }).crypto;
64
+ this.httpClient = config.httpClient ?? (config.fetch ? undefined : axios.create({ baseURL: this.baseUrl }));
65
+ this.retryTimes = config.retryTimes ?? 10;
66
+ this.retryDelayMs = config.retryDelayMs ?? 1000;
67
+ this.defaultExpSeconds = config.defaultExpSeconds ?? 300;
68
+ this.idToken = config.idToken;
69
+ this.vpToken = config.vpToken;
70
+ }
71
+
72
+ setIdToken(idToken: string): void {
73
+ this.idToken = idToken;
74
+ }
75
+
76
+ setVpToken(vpToken: string): void {
77
+ this.vpToken = vpToken;
78
+ }
79
+
80
+ getLastTenantConfigResponse(): DataConvDidCommResponse<TenantAdapterConfigResource> | undefined {
81
+ return this.lastTenantConfigResponse;
82
+ }
83
+
84
+ getLastConversionResponse(): DataConvDidCommResponse<ConvertedBundleResource> | undefined {
85
+ return this.lastConversionResponse;
86
+ }
87
+
88
+ clearStoredResponses(): void {
89
+ this.lastTenantConfigResponse = undefined;
90
+ this.lastConversionResponse = undefined;
91
+ }
92
+
93
+ getTenantConfigEntries(
94
+ response: DataConvDidCommResponse<TenantAdapterConfigResource> | undefined = this.lastTenantConfigResponse
95
+ ): Array<TenantAdapterConfigEntry<TenantAdapterConfigResource>> {
96
+ const entries = response?.body?.data;
97
+ return Array.isArray(entries) ? entries : [];
98
+ }
99
+
100
+ getSuccessfulTenantConfigs(
101
+ response: DataConvDidCommResponse<TenantAdapterConfigResource> | undefined = this.lastTenantConfigResponse
102
+ ): TenantAdapterConfigResource[] {
103
+ return this.getTenantConfigEntries(response)
104
+ .filter((entry) => typeof entry.response?.status === 'string' && entry.response.status.startsWith('2'))
105
+ .map((entry) => entry.resource)
106
+ .filter((resource): resource is TenantAdapterConfigResource => !!resource && typeof resource === 'object');
107
+ }
108
+
109
+ getConversionEntry(
110
+ response: DataConvDidCommResponse<ConvertedBundleResource> | undefined = this.lastConversionResponse
111
+ ): ConversionResultEntry | undefined {
112
+ const entries = response?.body?.data;
113
+ if (!Array.isArray(entries)) {
114
+ return undefined;
115
+ }
116
+ return entries.find((entry) => entry?.type === 'ConversionResult') as ConversionResultEntry | undefined;
117
+ }
118
+
119
+ getConvertedBundle(
120
+ response: DataConvDidCommResponse<ConvertedBundleResource> | undefined = this.lastConversionResponse
121
+ ): ConvertedBundleResource | undefined {
122
+ return this.getConversionEntry(response)?.resource;
123
+ }
124
+
125
+ getResponseIssues<TResource>(
126
+ response: DataConvDidCommResponse<TResource> | undefined
127
+ ): DataConvOperationOutcome | undefined {
128
+ return response?.body?.issues;
129
+ }
130
+
131
+ async createTenantConfig(options: CreateTenantConfigOptions): Promise<DataConvCreateResult> {
132
+ return this.createConfig(options);
133
+ }
134
+
135
+ async createConfig(options: CreateTenantConfigOptions): Promise<DataConvCreateResult> {
136
+ if (!Array.isArray(options.entries) || options.entries.length === 0) {
137
+ throw new Error('entries is required and must contain at least one item');
138
+ }
139
+
140
+ const tenantId = resolveConfigTenantId(this.config, options.tenantId ?? options.alternateName);
141
+ const jurisdiction = resolveJurisdiction(this.config, options.jurisdiction);
142
+ const sector = resolveSector(this.config, options.sector);
143
+ const softwareId = resolveConfigSoftwareId(options);
144
+ const envelope = buildEnvelope({
145
+ iss: options.iss,
146
+ thid: options.thid,
147
+ type: options.type,
148
+ iat: options.iat,
149
+ exp: options.exp,
150
+ idToken: options.idToken,
151
+ vpToken: options.vpToken,
152
+ data: options.entries
153
+ }, this.messageDeps());
154
+
155
+ const response = await this.request({
156
+ method: 'POST',
157
+ url: `/host/cds-${jurisdiction}/v1/${sector}/${tenantId}/${softwareId}/config/_create`,
158
+ headers: { 'Content-Type': 'application/didcomm-plain+json' },
159
+ body: envelope
160
+ });
161
+
162
+ if (response.status !== 202) {
163
+ throw new Error(`Unexpected createTenantConfig response status: ${response.status}`);
164
+ }
165
+
166
+ return {
167
+ thid: envelope.thid,
168
+ location: headerValue(response.headers, 'location')
169
+ };
170
+ }
171
+
172
+ async pollTenantConfigResponse(
173
+ options: DataConvTenantConfigPollOptions
174
+ ): Promise<DataConvDidCommResponse<TenantAdapterConfigResource>> {
175
+ return this.pollConfig(options);
176
+ }
177
+
178
+ async pollConfig(
179
+ options: DataConvTenantConfigPollOptions
180
+ ): Promise<DataConvDidCommResponse<TenantAdapterConfigResource>> {
181
+ const tenantId = resolveConfigTenantId(this.config, options.tenantId ?? options.alternateName);
182
+ const jurisdiction = resolveJurisdiction(this.config, options.jurisdiction);
183
+ const sector = resolveSector(this.config, options.sector);
184
+ const softwareId = requireText(options.softwareId, 'softwareId');
185
+ const thid = requireText(options.thid, 'thid');
186
+
187
+ const response = await this.pollUntilComplete<DataConvDidCommResponse<TenantAdapterConfigResource>>(
188
+ async () => this.request({
189
+ method: 'POST',
190
+ url: `/host/cds-${jurisdiction}/v1/${sector}/${tenantId}/${softwareId}/config/_create-response?thid=${encodeURIComponent(thid)}`,
191
+ headers: { 'Content-Type': 'application/didcomm-plain+json' },
192
+ body: buildEnvelope({
193
+ iss: options.iss,
194
+ thid,
195
+ type: options.type,
196
+ iat: options.iat,
197
+ exp: options.exp,
198
+ idToken: options.idToken,
199
+ vpToken: options.vpToken
200
+ }, this.messageDeps())
201
+ }),
202
+ `Failed polling tenant config response after ${this.retryTimes} attempts`
203
+ );
204
+
205
+ this.lastTenantConfigResponse = response;
206
+ return response;
207
+ }
208
+
209
+ async createTenantConfigAndWait(
210
+ options: CreateTenantConfigOptions
211
+ ): Promise<DataConvDidCommResponse<TenantAdapterConfigResource>> {
212
+ const { thid } = await this.createConfig(options);
213
+ return this.pollConfig({
214
+ alternateName: options.alternateName,
215
+ tenantId: options.tenantId,
216
+ jurisdiction: options.jurisdiction,
217
+ sector: options.sector,
218
+ softwareId: resolveConfigSoftwareId(options),
219
+ thid,
220
+ iss: options.iss,
221
+ type: options.type,
222
+ idToken: options.idToken,
223
+ vpToken: options.vpToken
224
+ });
225
+ }
226
+
227
+ async uploadSpreadsheet(
228
+ source: string | Uint8Array,
229
+ options: DataConvUploadDidCommOptions
230
+ ): Promise<DataConvUploadResult> {
231
+ if (typeof source === 'string') {
232
+ return this.uploadWithLink(source, options);
233
+ }
234
+ return this.uploadWithBinary(source, options);
235
+ }
236
+
237
+ async uploadWithLink(
238
+ source: string,
239
+ options: DataConvUploadDidCommOptions
240
+ ): Promise<DataConvUploadResult> {
241
+ return this.uploadDidComm(source, options);
242
+ }
243
+
244
+ async uploadWithBinary(
245
+ source: Uint8Array,
246
+ options: DataConvUploadDidCommOptions
247
+ ): Promise<DataConvUploadResult> {
248
+ return this.uploadDidComm(source, options);
249
+ }
250
+
251
+ private async uploadDidComm(
252
+ source: string | Uint8Array,
253
+ options: DataConvUploadDidCommOptions
254
+ ): Promise<DataConvUploadResult> {
255
+ const tenantId = resolveTenantId(this.config, options.tenantId ?? options.alternateName);
256
+ const jurisdiction = resolveJurisdiction(this.config, options.jurisdiction);
257
+ const sector = resolveSector(this.config, options.sector);
258
+ const softwareId = requireText(options.softwareId, 'softwareId');
259
+ const resourceType = resolveResourceType(this.config, options.resourceType);
260
+ const envelope = buildEnvelope({
261
+ iss: options.iss,
262
+ thid: options.thid,
263
+ type: options.type,
264
+ iat: options.iat,
265
+ exp: options.exp,
266
+ idToken: options.idToken,
267
+ vpToken: options.vpToken,
268
+ body: options.body ?? DEFAULT_UPLOAD_BODY,
269
+ attachments: [buildAttachment(source, options, this.cryptoApi)],
270
+ extra: buildUploadExtra(options, this.config)
271
+ }, this.messageDeps());
272
+
273
+ const response = await this.request({
274
+ method: 'POST',
275
+ url: `/${tenantId}/cds-${jurisdiction}/v1/${sector}/digitaltwin/${softwareId}/${resourceType}/_upload`,
276
+ headers: { 'Content-Type': 'application/didcomm-plain+json' },
277
+ body: envelope
278
+ });
279
+
280
+ if (response.status !== 202) {
281
+ throw new Error(`Unexpected uploadSpreadsheet response status: ${response.status}`);
282
+ }
283
+
284
+ return {
285
+ thid: envelope.thid,
286
+ location: headerValue(response.headers, 'location')
287
+ };
288
+ }
289
+
290
+ async uploadSpreadsheetMultipart(options: DataConvMultipartUploadOptions): Promise<DataConvUploadResult> {
291
+ return this.uploadWithFile(options);
292
+ }
293
+
294
+ async uploadWithFile(options: DataConvMultipartUploadOptions): Promise<DataConvUploadResult> {
295
+ const tenantId = resolveTenantId(this.config, options.tenantId ?? options.alternateName);
296
+ const jurisdiction = resolveJurisdiction(this.config, options.jurisdiction);
297
+ const sector = resolveSector(this.config, options.sector);
298
+ const softwareId = requireText(options.softwareId, 'softwareId');
299
+ const resourceType = resolveResourceType(this.config, options.resourceType);
300
+ const sourceFormat = normalizeSourceFormat(options.sourceFormat, this.config);
301
+ const envelope = buildEnvelope({
302
+ iss: options.iss,
303
+ thid: options.thid,
304
+ type: options.type,
305
+ iat: options.iat,
306
+ exp: options.exp,
307
+ idToken: options.idToken,
308
+ vpToken: options.vpToken,
309
+ extra: buildUploadExtra(options, this.config)
310
+ }, this.messageDeps());
311
+
312
+ const formData = buildMultipartFormData(
313
+ options.fileBytes,
314
+ options.fileName || (sourceFormat === 'csv' ? 'input.csv' : 'input.xlsx'),
315
+ options.mediaType || (sourceFormat === 'csv' ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
316
+ envelope
317
+ );
318
+
319
+ const response = await this.request({
320
+ method: 'POST',
321
+ url: `/${tenantId}/cds-${jurisdiction}/v1/${sector}/digitaltwin/${softwareId}/${resourceType}/_upload`,
322
+ body: formData
323
+ });
324
+
325
+ if (response.status !== 202) {
326
+ throw new Error(`Unexpected uploadSpreadsheetMultipart response status: ${response.status}`);
327
+ }
328
+
329
+ return {
330
+ thid: envelope.thid,
331
+ location: headerValue(response.headers, 'location')
332
+ };
333
+ }
334
+
335
+ async pollConversionResponse(
336
+ options: DataConvConversionPollOptions
337
+ ): Promise<DataConvDidCommResponse<ConvertedBundleResource>> {
338
+ return this.pollUploadResponse(options);
339
+ }
340
+
341
+ async pollUploadResponse(
342
+ options: DataConvConversionPollOptions
343
+ ): Promise<DataConvDidCommResponse<ConvertedBundleResource>> {
344
+ const tenantId = resolveTenantId(this.config, options.tenantId ?? options.alternateName);
345
+ const jurisdiction = resolveJurisdiction(this.config, options.jurisdiction);
346
+ const sector = resolveSector(this.config, options.sector);
347
+ const softwareId = requireText(options.softwareId, 'softwareId');
348
+ const resourceType = resolveResourceType(this.config, options.resourceType);
349
+ const thid = requireText(options.thid, 'thid');
350
+
351
+ const response = await this.pollUntilComplete<DataConvDidCommResponse<ConvertedBundleResource>>(
352
+ async () => this.request({
353
+ method: 'POST',
354
+ url: `/${tenantId}/cds-${jurisdiction}/v1/${sector}/digitaltwin/${softwareId}/${resourceType}/_upload-response?thid=${encodeURIComponent(thid)}`,
355
+ headers: { 'Content-Type': 'application/didcomm-plain+json' },
356
+ body: buildEnvelope({
357
+ iss: options.iss,
358
+ thid,
359
+ type: options.type,
360
+ iat: options.iat,
361
+ exp: options.exp,
362
+ idToken: options.idToken,
363
+ vpToken: options.vpToken
364
+ }, this.messageDeps())
365
+ }),
366
+ `Failed polling conversion response after ${this.retryTimes} attempts`
367
+ );
368
+
369
+ this.lastConversionResponse = response;
370
+ return response;
371
+ }
372
+
373
+ async uploadSpreadsheetAndWait(
374
+ source: string | Uint8Array,
375
+ options: DataConvUploadDidCommOptions
376
+ ): Promise<DataConvDidCommResponse<ConvertedBundleResource>> {
377
+ const { thid } = await this.uploadSpreadsheet(source, options);
378
+ return this.pollUploadResponse({
379
+ alternateName: options.alternateName,
380
+ tenantId: options.tenantId,
381
+ jurisdiction: options.jurisdiction,
382
+ sector: options.sector,
383
+ softwareId: options.softwareId,
384
+ resourceType: options.resourceType,
385
+ thid,
386
+ iss: options.iss,
387
+ type: options.type,
388
+ idToken: options.idToken,
389
+ vpToken: options.vpToken
390
+ });
391
+ }
392
+
393
+ async patchConversion(options: DataConvPatchOptions): Promise<DataConvPatchResponse> {
394
+ const tenantId = resolveTenantId(this.config, options.tenantId ?? options.alternateName);
395
+ const jurisdiction = resolveJurisdiction(this.config, options.jurisdiction);
396
+ const sector = resolveSector(this.config, options.sector);
397
+ const softwareId = requireText(options.softwareId, 'softwareId');
398
+ const resourceType = requireText(options.resourceType || 'Composition', 'resourceType');
399
+ const thid = requireText(options.thid, 'thid');
400
+
401
+ const response = await this.request({
402
+ method: 'POST',
403
+ url: `/${tenantId}/cds-${jurisdiction}/v1/${sector}/digitaltwin/${softwareId}/${resourceType}/_patch?thid=${encodeURIComponent(thid)}`,
404
+ headers: { 'Content-Type': 'application/didcomm-plain+json' },
405
+ body: buildEnvelope({
406
+ iss: options.iss,
407
+ thid,
408
+ type: options.type,
409
+ iat: options.iat,
410
+ exp: options.exp,
411
+ idToken: options.idToken,
412
+ vpToken: options.vpToken
413
+ }, this.messageDeps())
414
+ });
415
+
416
+ if (response.status !== 200) {
417
+ throw new Error(`Unexpected patchConversion response status: ${response.status}`);
418
+ }
419
+
420
+ return response.data as DataConvPatchResponse;
421
+ }
422
+
423
+ async batchPromotion(options: DataConvBatchOptions): Promise<DataConvPatchResponse> {
424
+ const tenantId = resolveTenantId(this.config, options.tenantId ?? options.alternateName);
425
+ const jurisdiction = resolveJurisdiction(this.config, options.jurisdiction);
426
+ const sector = resolveSector(this.config, options.sector);
427
+ const softwareId = requireText(options.softwareId, 'softwareId');
428
+ const resourceType = requireText(options.resourceType || 'Patient', 'resourceType');
429
+ const thid = requireText(options.thid, 'thid');
430
+
431
+ const response = await this.request({
432
+ method: 'POST',
433
+ url: `/${tenantId}/cds-${jurisdiction}/v1/${sector}/digitaltwin/${softwareId}/${resourceType}/_batch?thid=${encodeURIComponent(thid)}`,
434
+ headers: { 'Content-Type': 'application/didcomm-plain+json' },
435
+ body: buildEnvelope({
436
+ iss: options.iss,
437
+ thid,
438
+ type: options.type,
439
+ iat: options.iat,
440
+ exp: options.exp,
441
+ idToken: options.idToken,
442
+ vpToken: options.vpToken
443
+ }, this.messageDeps())
444
+ });
445
+
446
+ if (response.status !== 200) {
447
+ throw new Error(`Unexpected batchPromotion response status: ${response.status}`);
448
+ }
449
+
450
+ return response.data as DataConvPatchResponse;
451
+ }
452
+
453
+ async searchResources<TResource = Record<string, unknown>>(
454
+ options: DataConvSearchOptions
455
+ ): Promise<DataConvSearchBundle<TResource>> {
456
+ const tenantId = resolveTenantId(this.config, options.tenantId ?? options.alternateName);
457
+ const jurisdiction = resolveJurisdiction(this.config, options.jurisdiction);
458
+ const sector = resolveSector(this.config, options.sector);
459
+ const resourceType = requireText(options.resourceType, 'resourceType');
460
+ const searchParams = normalizeSearchParams(
461
+ options.searchParams && typeof options.searchParams === 'object'
462
+ ? options.searchParams as Record<string, unknown>
463
+ : undefined
464
+ );
465
+ const authToken = String(options.authorizationToken || options.idToken || this.idToken || '').trim();
466
+ const headers: Record<string, string> = {
467
+ 'Content-Type': 'application/json'
468
+ };
469
+
470
+ if (authToken) {
471
+ headers.Authorization = `Bearer ${authToken}`;
472
+ }
473
+
474
+ const response = await this.request({
475
+ method: 'POST',
476
+ url: `/host/cds-${jurisdiction}/v1/${sector}/${tenantId}/org.hl7.fhir.api/${resourceType}/_search`,
477
+ headers,
478
+ body: searchParams
479
+ });
480
+
481
+ if (response.status !== 200) {
482
+ throw new Error(`Unexpected searchResources response status: ${response.status}`);
483
+ }
484
+
485
+ return response.data as DataConvSearchBundle<TResource>;
486
+ }
487
+
488
+ private async pollUntilComplete<T>(
489
+ requestFactory: () => Promise<{ status: number; headers: Record<string, string>; data: unknown }>,
490
+ errorMessage: string
491
+ ): Promise<T> {
492
+ for (let attempt = 0; attempt < this.retryTimes; attempt += 1) {
493
+ const response = await requestFactory();
494
+ if (response.status === 200) {
495
+ return response.data as T;
496
+ }
497
+
498
+ const retryAfter = headerValue(response.headers, 'retry-after');
499
+ const retrySeconds = retryAfter ? Number(retryAfter) : undefined;
500
+ const delayMs = retrySeconds !== undefined && !Number.isNaN(retrySeconds)
501
+ ? retrySeconds * 1000
502
+ : this.retryDelayMs;
503
+ await sleep(delayMs);
504
+ }
505
+
506
+ throw new Error(errorMessage);
507
+ }
508
+
509
+ private async request(options: {
510
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
511
+ url: string;
512
+ headers?: Record<string, string>;
513
+ body?: unknown;
514
+ }): Promise<{ status: number; headers: Record<string, string>; data: unknown }> {
515
+ const headers = options.headers ?? {};
516
+
517
+ if (this.httpClient) {
518
+ const response = await this.httpClient.request({
519
+ method: options.method,
520
+ url: options.url,
521
+ data: options.body,
522
+ headers,
523
+ validateStatus: () => true
524
+ });
525
+
526
+ return {
527
+ status: response.status,
528
+ headers: (response.headers || {}) as Record<string, string>,
529
+ data: response.data
530
+ };
531
+ }
532
+
533
+ if (!this.fetchFn) {
534
+ throw new Error('No HTTP transport available: provide axios httpClient or fetch implementation');
535
+ }
536
+
537
+ const response = await this.fetchFn(buildUrl(this.baseUrl, options.url), {
538
+ method: options.method,
539
+ headers,
540
+ body: options.body === undefined
541
+ ? undefined
542
+ : isFormData(options.body)
543
+ ? options.body
544
+ : typeof options.body === 'string'
545
+ ? options.body
546
+ : JSON.stringify(options.body)
547
+ });
548
+
549
+ const contentType = response.headers.get('content-type') || '';
550
+ const data = contentType.includes('json') ? await response.json() : await response.text();
551
+ return {
552
+ status: response.status,
553
+ headers: headersToObject(response.headers),
554
+ data
555
+ };
556
+ }
557
+
558
+ private messageDeps() {
559
+ return {
560
+ config: this.config,
561
+ cryptoApi: this.cryptoApi,
562
+ defaultExpSeconds: this.defaultExpSeconds,
563
+ currentIdToken: this.idToken,
564
+ currentVpToken: this.vpToken
565
+ };
566
+ }
567
+ }