@haste-health/client 0.15.3

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,673 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { Bundle, id } from "@haste-health/fhir-types/r4/types";
3
+ import * as r4b from "@haste-health/fhir-types/r4b/types";
4
+ import { FHIR_VERSION, R4, R4B } from "@haste-health/fhir-types/versions";
5
+ import { OperationError, outcomeError } from "@haste-health/operation-outcomes";
6
+
7
+ import { AsynchronousClient } from "../index.js";
8
+ import { MiddlewareAsync, createMiddlewareAsync } from "../middleware/index.js";
9
+ import {
10
+ AllInteractions,
11
+ FHIRErrorResponse,
12
+ FHIRRequest,
13
+ FHIRResponse,
14
+ } from "../types/index.js";
15
+ import { ParsedParameter } from "../url.js";
16
+
17
+ type DeriveFHIRURL = (fhirVersion: FHIR_VERSION) => string;
18
+ export type HTTPClientState = {
19
+ authenticate?: () => void;
20
+ getAccessToken?: () => Promise<string>;
21
+ url: string | DeriveFHIRURL;
22
+ };
23
+
24
+ export type HTTPContext = {
25
+ headers?: Record<string, string>;
26
+ };
27
+
28
+ function parametersToQueryString(
29
+ parameters: ParsedParameter<string | number>[]
30
+ ): string {
31
+ return parameters
32
+ .map((p) => {
33
+ const name = p.chains ? [p.name, ...p.chains].join(".") : p.name;
34
+ return `${name}${p.modifier ? `:${p.modifier}` : ""}=${p.value
35
+ .map((v) => encodeURIComponent(v))
36
+ .join(",")}`;
37
+ })
38
+ .join("&");
39
+ }
40
+
41
+ const pathJoin = (parts: string[], sep = "/") =>
42
+ parts.join(sep).replace(new RegExp(sep + "{1,}", "g"), sep);
43
+
44
+ function fhirUrlChunk(version: string) {
45
+ switch (version) {
46
+ case R4:
47
+ return "r4";
48
+ case R4B:
49
+ return "r4b";
50
+ default:
51
+ return version;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Used as default and for display purposes in admin app.
57
+ * @param domain HasteHealth Domain
58
+ * @param fhirVersion FHIRVersion
59
+ * @returns HasteHealth VersionedURL.
60
+ */
61
+ export const deriveHasteHealthVersionedURL = (
62
+ domain: string,
63
+ fhirVersion: FHIR_VERSION
64
+ ) => {
65
+ return new URL(
66
+ pathJoin([new URL(domain).pathname, `/${fhirUrlChunk(fhirVersion)}`]),
67
+ domain
68
+ ).toString();
69
+ };
70
+
71
+ async function toHTTPRequest(
72
+ state: HTTPClientState,
73
+ context: HTTPContext,
74
+ request: FHIRRequest<FHIR_VERSION, AllInteractions>
75
+ ): Promise<{
76
+ url: string;
77
+ headers?: Record<string, string>;
78
+ method: string;
79
+ body?: string;
80
+ }> {
81
+ const headers: Record<string, string> = {
82
+ "Content-Type": "application/fhir+json",
83
+ ...context.headers,
84
+ };
85
+
86
+ let FHIRUrl =
87
+ typeof state.url === "string"
88
+ ? deriveHasteHealthVersionedURL(state.url, request.fhirVersion)
89
+ : state.url(request.fhirVersion);
90
+ if (!FHIRUrl.endsWith("/")) {
91
+ FHIRUrl = FHIRUrl + "/";
92
+ }
93
+
94
+ if (state.getAccessToken) {
95
+ const token = await state.getAccessToken();
96
+ headers["Authorization"] = `Bearer ${token}`;
97
+ }
98
+ switch (request.type) {
99
+ case "capabilities-request": {
100
+ return { headers, url: new URL("metadata", FHIRUrl).href, method: "GET" };
101
+ }
102
+
103
+ case "create-request": {
104
+ return {
105
+ url: new URL(request.resource, FHIRUrl).href,
106
+ method: "POST",
107
+ body: JSON.stringify(request.body),
108
+ headers,
109
+ };
110
+ }
111
+ case "update-request": {
112
+ switch (request.level) {
113
+ case "instance": {
114
+ return {
115
+ url: new URL(`${request.resource}/${request.id}`, FHIRUrl).href,
116
+ method: "PUT",
117
+ body: JSON.stringify(request.body),
118
+ headers,
119
+ };
120
+ }
121
+ case "type": {
122
+ const queryString = parametersToQueryString(request.parameters);
123
+ return {
124
+ url: new URL(
125
+ `${request.resource}${queryString ? `?${queryString}` : ""}`,
126
+ FHIRUrl
127
+ ).href,
128
+ method: "PUT",
129
+ body: JSON.stringify(request.body),
130
+ headers,
131
+ };
132
+ }
133
+ default: {
134
+ throw new OperationError(outcomeError("exception", "Invalid level"));
135
+ }
136
+ }
137
+ }
138
+ case "patch-request": {
139
+ return {
140
+ url: new URL(`${request.resource}/${request.id}`, FHIRUrl).href,
141
+ method: "PATCH",
142
+ body: JSON.stringify(request.body),
143
+ headers,
144
+ };
145
+ }
146
+ case "read-request": {
147
+ return {
148
+ url: new URL(`${request.resource}/${request.id}`, FHIRUrl).href,
149
+ method: "GET",
150
+ headers,
151
+ };
152
+ }
153
+ case "vread-request": {
154
+ return {
155
+ url: new URL(
156
+ `${request.resource}/${request.id}/_history/${request.versionId}`,
157
+ FHIRUrl
158
+ ).href,
159
+ method: "GET",
160
+ headers,
161
+ };
162
+ }
163
+ case "delete-request": {
164
+ switch (request.level) {
165
+ case "instance": {
166
+ return {
167
+ url: new URL(`${request.resource}/${request.id}`, FHIRUrl).href,
168
+ method: "DELETE",
169
+ headers,
170
+ };
171
+ }
172
+ case "type": {
173
+ const queryString = parametersToQueryString(request.parameters);
174
+ return {
175
+ url: new URL(
176
+ `${request.resource}${queryString ? `?${queryString}` : ""}`,
177
+ FHIRUrl
178
+ ).href,
179
+ method: "DELETE",
180
+ headers,
181
+ };
182
+ }
183
+ case "system": {
184
+ const queryString = parametersToQueryString(request.parameters);
185
+ return {
186
+ url: new URL(`${queryString ? `?${queryString}` : ""}`, FHIRUrl)
187
+ .href,
188
+ method: "DELETE",
189
+ headers,
190
+ };
191
+ }
192
+ }
193
+ throw new OperationError(outcomeError("exception", "Invalid level"));
194
+ }
195
+ case "history-request": {
196
+ let historyUrl;
197
+ const queryString = parametersToQueryString(request.parameters || []);
198
+ switch (request.level) {
199
+ case "instance": {
200
+ historyUrl = new URL(
201
+ `${request.resource}/${request.id}/_history`,
202
+ FHIRUrl
203
+ ).href;
204
+ break;
205
+ }
206
+ case "type": {
207
+ historyUrl = new URL(`${request.resource}/_history`, FHIRUrl).href;
208
+ break;
209
+ }
210
+ case "system": {
211
+ historyUrl = new URL(`_history`, FHIRUrl).href;
212
+ break;
213
+ }
214
+ }
215
+
216
+ return {
217
+ url: new URL(`${queryString ? `?${queryString}` : ""}`, historyUrl)
218
+ .href,
219
+ method: "GET",
220
+ headers,
221
+ };
222
+ }
223
+
224
+ case "batch-request":
225
+ case "transaction-request": {
226
+ return {
227
+ url: FHIRUrl,
228
+ method: "POST",
229
+ body: JSON.stringify(request.body),
230
+ headers,
231
+ };
232
+ }
233
+ case "search-request": {
234
+ const queryString = parametersToQueryString(request.parameters);
235
+ let searchURL;
236
+ switch (request.level) {
237
+ case "type":
238
+ searchURL = new URL(
239
+ `${request.resource}${queryString ? `?${queryString}` : ""}`,
240
+ FHIRUrl
241
+ ).href;
242
+ break;
243
+ case "system":
244
+ searchURL = new URL(
245
+ `${queryString ? `?${queryString}` : ""}`,
246
+ FHIRUrl
247
+ ).href;
248
+ break;
249
+ }
250
+
251
+ return {
252
+ url: searchURL,
253
+ method: "GET",
254
+ headers,
255
+ };
256
+ }
257
+
258
+ case "invoke-request": {
259
+ let invokeURL;
260
+ switch (request.level) {
261
+ case "instance":
262
+ invokeURL = new URL(
263
+ `${request.resource}/${request.id}/$${request.operation}`,
264
+ FHIRUrl
265
+ ).href;
266
+ break;
267
+ case "type":
268
+ invokeURL = new URL(
269
+ `${request.resource}/$${request.operation}`,
270
+ FHIRUrl
271
+ ).href;
272
+ break;
273
+ case "system":
274
+ invokeURL = new URL(`$${request.operation}`, FHIRUrl).href;
275
+ break;
276
+ }
277
+ return {
278
+ url: invokeURL,
279
+ method: "POST",
280
+ body: JSON.stringify(request.body),
281
+ headers,
282
+ };
283
+ }
284
+ }
285
+ }
286
+
287
+ export class ResponseError<Version extends FHIR_VERSION> extends Error {
288
+ private readonly _request: FHIRRequest<Version, AllInteractions>;
289
+ private readonly _response: FHIRResponse<Version, "error">;
290
+ constructor(
291
+ request: FHIRRequest<Version, AllInteractions>,
292
+ response: FHIRErrorResponse<Version>
293
+ ) {
294
+ super();
295
+ this._request = request;
296
+ this._response = response;
297
+ }
298
+ get response() {
299
+ return this._response;
300
+ }
301
+ get request() {
302
+ return this._request;
303
+ }
304
+ }
305
+
306
+ export function isResponseError(e: unknown): e is ResponseError<FHIR_VERSION> {
307
+ return e instanceof ResponseError;
308
+ }
309
+
310
+ async function httpResponseToFHIRResponse<Version extends FHIR_VERSION>(
311
+ request: FHIRRequest<Version, AllInteractions>,
312
+ response: Response
313
+ ): Promise<FHIRResponse<Version, AllInteractions>> {
314
+ if (response.status >= 400) {
315
+ switch (response.status) {
316
+ case 401: {
317
+ throw new ResponseError(request, {
318
+ fhirVersion: request.fhirVersion,
319
+ level: request.level,
320
+ type: "error-response",
321
+ body: outcomeError("login", "Unauthorized") as any,
322
+ http: {
323
+ status: response.status,
324
+ headers: Object.fromEntries(response.headers),
325
+ },
326
+ });
327
+ }
328
+ case 403: {
329
+ throw new ResponseError(request, {
330
+ fhirVersion: request.fhirVersion,
331
+ level: request.level,
332
+ type: "error-response",
333
+ body: outcomeError("forbidden", "Forbidden") as any,
334
+ http: {
335
+ status: response.status,
336
+ headers: Object.fromEntries(response.headers),
337
+ },
338
+ });
339
+ }
340
+ default: {
341
+ if (!response.body) throw new Error(response.statusText);
342
+ const oo = await response.json();
343
+
344
+ if (!("resourceType" in oo) || oo.resourceType !== "OperationOutcome") {
345
+ throw new Error(response.statusText);
346
+ }
347
+
348
+ throw new ResponseError(request, {
349
+ fhirVersion: request.fhirVersion,
350
+ level: request.level,
351
+ type: "error-response",
352
+ body: oo,
353
+ http: {
354
+ status: response.status,
355
+ headers: Object.fromEntries(response.headers),
356
+ },
357
+ });
358
+ }
359
+ }
360
+ }
361
+ switch (request.type) {
362
+ case "invoke-request": {
363
+ if (!response.body)
364
+ throw new OperationError(outcomeError("exception", "No response body"));
365
+ const parameters = await response.json();
366
+ switch (request.level) {
367
+ case "system": {
368
+ return {
369
+ fhirVersion: request.fhirVersion,
370
+ type: "invoke-response",
371
+ operation: request.operation,
372
+ level: "system",
373
+ body: parameters,
374
+ } as FHIRResponse<Version, "invoke">;
375
+ }
376
+ case "type": {
377
+ return {
378
+ fhirVersion: request.fhirVersion,
379
+ type: "invoke-response",
380
+ operation: request.operation,
381
+ level: "type",
382
+ resource: request.resource,
383
+ body: parameters,
384
+ } as FHIRResponse<Version, "invoke">;
385
+ }
386
+ case "instance": {
387
+ return {
388
+ fhirVersion: request.fhirVersion,
389
+ type: "invoke-response",
390
+ operation: request.operation,
391
+ level: "instance",
392
+ resource: request.resource,
393
+ id: request.id,
394
+ body: parameters,
395
+ } as FHIRResponse<Version, "invoke">;
396
+ }
397
+ }
398
+ throw new OperationError(outcomeError("exception", "Invalid level"));
399
+ }
400
+ case "read-request": {
401
+ if (!response.body)
402
+ throw new OperationError(outcomeError("exception", "No response body"));
403
+ const resource = await response.json();
404
+ return {
405
+ fhirVersion: request.fhirVersion,
406
+ level: "instance",
407
+ type: "read-response",
408
+ resource: request.resource,
409
+ id: request.id,
410
+ body: resource,
411
+ } as FHIRResponse<Version, "read">;
412
+ }
413
+
414
+ case "vread-request": {
415
+ if (!response.body)
416
+ throw new OperationError(outcomeError("exception", "No response body"));
417
+ const vresource = await response.json();
418
+ return {
419
+ fhirVersion: request.fhirVersion,
420
+ level: "instance",
421
+ type: "vread-response",
422
+ resource: request.resource,
423
+ id: request.id,
424
+ versionId: request.versionId,
425
+ body: vresource,
426
+ } as FHIRResponse<Version, "vread">;
427
+ }
428
+ case "update-request": {
429
+ if (!response.body)
430
+ throw new OperationError(outcomeError("exception", "No response body"));
431
+ const uresource = await response.json();
432
+
433
+ switch (request.level) {
434
+ case "instance": {
435
+ return {
436
+ fhirVersion: request.fhirVersion,
437
+ type: "update-response",
438
+ level: "instance",
439
+ resource: request.resource,
440
+ id: request.id,
441
+ body: uresource,
442
+ } as FHIRResponse<Version, "update">;
443
+ }
444
+ case "type": {
445
+ const location = response.headers.get("Location") ?? "";
446
+ const parts = location.split("/");
447
+ return {
448
+ fhirVersion: request.fhirVersion,
449
+ type: "update-response",
450
+ level: "instance",
451
+ resource: request.resource,
452
+ id: parts[parts.length - 1] as id,
453
+ body: uresource,
454
+ } as FHIRResponse<Version, "update">;
455
+ }
456
+ default: {
457
+ throw new OperationError(outcomeError("exception", "Invalid level"));
458
+ }
459
+ }
460
+ }
461
+ case "patch-request": {
462
+ if (!response.body)
463
+ throw new OperationError(outcomeError("exception", "No response body"));
464
+ const presource = await response.json();
465
+ return {
466
+ fhirVersion: request.fhirVersion,
467
+ type: "patch-response",
468
+ level: "instance",
469
+ resource: request.resource,
470
+ id: request.id,
471
+ body: presource,
472
+ } as FHIRResponse<Version, "patch">;
473
+ }
474
+
475
+ case "delete-request": {
476
+ switch (request.level) {
477
+ case "instance": {
478
+ return {
479
+ fhirVersion: request.fhirVersion,
480
+ type: "delete-response",
481
+ level: "instance",
482
+ resource: request.resource,
483
+ id: request.id,
484
+ } as FHIRResponse<Version, "delete">;
485
+ }
486
+ case "type": {
487
+ return {
488
+ fhirVersion: request.fhirVersion,
489
+ type: "delete-response",
490
+ level: "type",
491
+ resource: request.resource,
492
+ parameters: request.parameters,
493
+ } as FHIRResponse<Version, "delete">;
494
+ }
495
+ case "system": {
496
+ return {
497
+ fhirVersion: request.fhirVersion,
498
+ type: "delete-response",
499
+ level: "system",
500
+ parameters: request.parameters,
501
+ } as FHIRResponse<Version, "delete">;
502
+ }
503
+ }
504
+ throw new OperationError(outcomeError("exception", "Invalid level"));
505
+ }
506
+
507
+ case "history-request": {
508
+ if (!response.body)
509
+ throw new OperationError(outcomeError("exception", "No response body"));
510
+
511
+ const bundle = (await response.json()) as Bundle | r4b.Bundle;
512
+ switch (request.level) {
513
+ case "system": {
514
+ return {
515
+ fhirVersion: request.fhirVersion,
516
+ type: "history-response",
517
+ level: "system",
518
+ body: bundle,
519
+ } as FHIRResponse<Version, "history">;
520
+ }
521
+ case "type": {
522
+ return {
523
+ fhirVersion: request.fhirVersion,
524
+ type: "history-response",
525
+ level: "type",
526
+ resource: request.resource,
527
+ body: bundle,
528
+ } as FHIRResponse<Version, "history">;
529
+ }
530
+ case "instance": {
531
+ return {
532
+ fhirVersion: request.fhirVersion,
533
+ type: "history-response",
534
+ level: "instance",
535
+ resource: request.resource,
536
+ id: request.id,
537
+ body: bundle,
538
+ } as FHIRResponse<Version, "history">;
539
+ }
540
+ }
541
+ throw new OperationError(outcomeError("exception", "Invalid level"));
542
+ }
543
+
544
+ case "create-request": {
545
+ if (!response.body)
546
+ throw new OperationError(outcomeError("exception", "No response body"));
547
+ const resource = await response.json();
548
+ return {
549
+ fhirVersion: request.fhirVersion,
550
+ type: "create-response",
551
+ level: "type",
552
+ resource: request.resource,
553
+ body: resource,
554
+ } as FHIRResponse<Version, "create">;
555
+ }
556
+
557
+ case "search-request": {
558
+ if (!response.body)
559
+ throw new OperationError(outcomeError("exception", "No response body"));
560
+ const bundle = (await response.json()) as Bundle | r4b.Bundle;
561
+ switch (request.level) {
562
+ case "system": {
563
+ return {
564
+ fhirVersion: request.fhirVersion,
565
+ type: "search-response",
566
+ level: "system",
567
+ parameters: request.parameters,
568
+ body: bundle,
569
+ } as FHIRResponse<Version, "search">;
570
+ }
571
+ case "type": {
572
+ return {
573
+ fhirVersion: request.fhirVersion,
574
+ type: "search-response",
575
+ level: "type",
576
+ parameters: request.parameters,
577
+ resource: request.resource,
578
+ body: bundle,
579
+ } as FHIRResponse<Version, "search">;
580
+ }
581
+ }
582
+ throw new OperationError(outcomeError("exception", "Invalid level"));
583
+ }
584
+
585
+ case "capabilities-request": {
586
+ if (!response.body)
587
+ throw new OperationError(outcomeError("exception", "No response body"));
588
+ const capabilities = await response.json();
589
+ return {
590
+ fhirVersion: request.fhirVersion,
591
+ level: "system",
592
+ type: "capabilities-response",
593
+ body: capabilities,
594
+ };
595
+ }
596
+
597
+ case "batch-request": {
598
+ if (!response.body)
599
+ throw new OperationError(outcomeError("exception", "No response body"));
600
+ const batch = await response.json();
601
+ return {
602
+ fhirVersion: request.fhirVersion,
603
+ type: "batch-response",
604
+ level: "system",
605
+ body: batch,
606
+ };
607
+ }
608
+
609
+ case "transaction-request": {
610
+ if (!response.body)
611
+ throw new OperationError(outcomeError("exception", "No response body"));
612
+ const transaction = await response.json();
613
+ return {
614
+ fhirVersion: request.fhirVersion,
615
+ type: "transaction-response",
616
+ level: "system",
617
+ body: transaction,
618
+ };
619
+ }
620
+ }
621
+ }
622
+
623
+ function httpMiddleware(state: HTTPClientState): MiddlewareAsync<HTTPContext> {
624
+ return createMiddlewareAsync<HTTPClientState, HTTPContext>(state, [
625
+ async (state, context) => {
626
+ try {
627
+ const httpRequest = await toHTTPRequest(
628
+ state,
629
+ context.ctx,
630
+ context.request
631
+ );
632
+ const response = await fetch(httpRequest.url, {
633
+ method: httpRequest.method,
634
+ headers: httpRequest.headers,
635
+ body: httpRequest.body,
636
+ });
637
+ const fhirResponse = await httpResponseToFHIRResponse(
638
+ context.request,
639
+ response
640
+ );
641
+ fhirResponse.http = {
642
+ status: response.status,
643
+ headers: Object.fromEntries(response.headers),
644
+ };
645
+ return [
646
+ state,
647
+ {
648
+ ...context,
649
+
650
+ response: fhirResponse,
651
+ },
652
+ ];
653
+ } catch (e) {
654
+ if (isResponseError(e)) {
655
+ if (e.response.body.issue[0].code === "login") {
656
+ if (state.authenticate) {
657
+ state.authenticate();
658
+ }
659
+ }
660
+ }
661
+ throw e;
662
+ }
663
+ },
664
+ ]);
665
+ }
666
+
667
+ export default function createHTTPClient(
668
+ initialState: HTTPClientState
669
+ ): AsynchronousClient<HTTPContext> {
670
+ // Removing trailing slash
671
+ const middleware = httpMiddleware(initialState);
672
+ return new AsynchronousClient<HTTPContext>(middleware);
673
+ }