@oystehr/sdk 4.3.8 → 4.3.9

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.
@@ -117,4 +117,71 @@ export type BatchInputRequest<F extends FhirResource> = BatchInputGetRequest | B
117
117
  export interface BatchInput<F extends FhirResource> {
118
118
  requests: BatchInputRequest<F>[];
119
119
  }
120
+ export interface FhirAsyncJobHandle {
121
+ jobId: string;
122
+ contentLocation: string;
123
+ mode: FhirAsyncResponseMode;
124
+ }
125
+ export type FhirResponseMode = 'sync' | 'async-bundle' | 'async-bulk';
126
+ export type FhirAsyncResponseMode = 'bundle' | 'bulk';
127
+ export interface FhirAsyncCompletionBundleEntry<T extends FhirResource> {
128
+ response?: {
129
+ status?: string;
130
+ outcome?: OperationOutcome;
131
+ };
132
+ resource?: T | OperationOutcome;
133
+ }
134
+ export type FhirAsyncCompletionBundle<T extends FhirResource> = EntrylessFhirBundle<T | OperationOutcome> & {
135
+ resourceType: 'Bundle';
136
+ type: 'batch-response';
137
+ entry?: Array<FhirAsyncCompletionBundleEntry<T>>;
138
+ };
139
+ export interface FhirAsyncJobInProgress {
140
+ status: 202;
141
+ xProgress?: string;
142
+ retryAfter?: string;
143
+ }
144
+ export interface FhirAsyncJobCompletedBundle<T extends FhirResource = FhirResource> {
145
+ status: 200;
146
+ mode: 'bundle';
147
+ bundle: FhirAsyncCompletionBundle<T>;
148
+ interactionStatus?: string;
149
+ resource?: T | OperationOutcome;
150
+ outcome?: OperationOutcome;
151
+ }
152
+ export interface FhirAsyncBulkOutputFile {
153
+ type: string;
154
+ url: string;
155
+ }
156
+ export interface FhirAsyncBulkManifest {
157
+ transactionTime: string;
158
+ request: string;
159
+ requiresAccessToken: boolean;
160
+ output: FhirAsyncBulkOutputFile[];
161
+ error: FhirAsyncBulkOutputFile[];
162
+ deleted?: FhirAsyncBulkOutputFile[];
163
+ extension?: Record<string, unknown>;
164
+ }
165
+ export interface FhirAsyncJobCompletedBulk {
166
+ status: 200;
167
+ mode: 'bulk';
168
+ manifest: FhirAsyncBulkManifest;
169
+ }
170
+ export interface FhirAsyncJobExpired {
171
+ status: 410;
172
+ }
173
+ export interface FhirAsyncJobNotFound {
174
+ status: 404;
175
+ }
176
+ export interface FhirAsyncJobUnexpected {
177
+ status: Exclude<number, 200 | 202 | 404 | 410>;
178
+ body: unknown;
179
+ }
180
+ export type FhirAsyncJobStatus<T extends FhirResource = FhirResource> = FhirAsyncJobInProgress | FhirAsyncJobCompletedBundle<T> | FhirAsyncJobCompletedBulk | FhirAsyncJobExpired | FhirAsyncJobNotFound | FhirAsyncJobUnexpected;
181
+ export interface FhirAsyncWaitOptions {
182
+ /** Poll interval in milliseconds. Defaults to 1000 (1 second). */
183
+ pollIntervalMs: number;
184
+ /** Maximum wait time in milliseconds before timing out. Defaults to 900000 (15 minutes). */
185
+ timeoutMs: number;
186
+ }
120
187
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oystehr/sdk",
3
- "version": "4.3.8",
3
+ "version": "4.3.9",
4
4
  "description": "Oystehr SDK",
5
5
  "scripts": {
6
6
  "lint": "eslint .",
@@ -2,7 +2,16 @@ import { v4 as uuidv4, validate as uuidValidate } from 'uuid';
2
2
  import { OystehrConfig } from '../config';
3
3
  import { OystehrFHIRError, OystehrSdkError } from '../errors';
4
4
  import { Logger } from '../logger';
5
- import { FhirBundle, FhirResource, OperationOutcome } from '../resources/types';
5
+ import {
6
+ FhirAsyncBulkManifest,
7
+ FhirAsyncCompletionBundle,
8
+ FhirAsyncJobHandle,
9
+ FhirAsyncJobStatus,
10
+ FhirBundle,
11
+ FhirResource,
12
+ FhirResponseMode,
13
+ OperationOutcome,
14
+ } from '../resources/types';
6
15
 
7
16
  type HttpMethod = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace';
8
17
  export const defaultProjectApiUrl = 'https://project-api.zapehr.com/v1';
@@ -41,10 +50,19 @@ export interface OystehrClientRequest {
41
50
  * Unique identifier for this request.
42
51
  */
43
52
  requestId?: string;
53
+ /**
54
+ * Optional execution mode for FHIR requests that support async behavior.
55
+ * Defaults to `sync` when omitted.
56
+ */
57
+ mode?: FhirResponseMode;
44
58
  }
45
59
 
46
60
  interface InternalClientRequest extends OystehrClientRequest {
47
61
  ifMatch?: string;
62
+ /**
63
+ * Internal-only: returns raw response metadata ({ status, headers, body }) instead of throwing on non-2xx statuses.
64
+ */
65
+ rawResponse?: boolean;
48
66
  }
49
67
 
50
68
  type FhirData<T extends FhirResource> = T | T[] | FhirBundle<T>;
@@ -75,14 +93,22 @@ export class SDKResource {
75
93
  };
76
94
  }
77
95
 
78
- protected fhirRequest<T extends FhirResource = any>(path: string, method: string) {
79
- return async (params: any, request?: InternalClientRequest): Promise<FhirFetcherResponse<T>> => {
96
+ protected fhirRequest<T = FhirResource>(
97
+ path: string,
98
+ method: string
99
+ ): {
100
+ (params: any, request: InternalClientRequest & { rawResponse: true }, requestMode?: FhirResponseMode): Promise<
101
+ RawFetcherResponse<T>
102
+ >;
103
+ (params: any, request?: InternalClientRequest, requestMode?: FhirResponseMode): Promise<T>;
104
+ } {
105
+ return async (params: any, request?: InternalClientRequest, requestMode?: FhirResponseMode): Promise<any> => {
80
106
  try {
81
107
  const baseUrlThunk = (): string => this.config.services?.fhirApiUrl ?? defaultFhirApiUrl;
82
108
  const configThunk = (): OystehrConfig => this.config;
83
109
  const loggerThunk = (): Logger => this.logger;
84
110
  // must await here to catch
85
- return await fetcher(baseUrlThunk, configThunk, loggerThunk, path, method)(params, request);
111
+ return await fetcher(baseUrlThunk, configThunk, loggerThunk, path, method)(params, request, requestMode);
86
112
  } catch (err: unknown) {
87
113
  // FHIR API error messages are JSON strings
88
114
  const fullError = err as { message: string | Record<string, any>; code: number; cause?: unknown };
@@ -100,13 +126,170 @@ export class SDKResource {
100
126
  }
101
127
  };
102
128
  }
129
+
130
+ protected async startAsyncJob(
131
+ path: string,
132
+ method: string,
133
+ params: Record<string, unknown>,
134
+ requestMode: FhirResponseMode,
135
+ request?: InternalClientRequest
136
+ ): Promise<FhirAsyncJobHandle> {
137
+ const mode = requestMode === 'async-bulk' ? 'bulk' : 'bundle';
138
+ const asyncPath = requestMode === 'async-bulk' ? this.appendBulkOutputFormat(path) : path;
139
+
140
+ const raw = await this.fhirRequest(asyncPath, method)(
141
+ params,
142
+ {
143
+ ...request,
144
+ rawResponse: true,
145
+ },
146
+ requestMode
147
+ );
148
+
149
+ if (raw.status !== 202) {
150
+ throw new OystehrSdkError({
151
+ message: `Expected start async job to return 202 Accepted, received ${raw.status}`,
152
+ code: raw.status,
153
+ });
154
+ }
155
+
156
+ const contentLocation = this.readHeader(raw.headers, 'content-location');
157
+ if (!contentLocation) {
158
+ throw new OystehrSdkError({
159
+ message: 'Start Async job response missing Content-Location header',
160
+ code: 500,
161
+ });
162
+ }
163
+
164
+ const jobId = this.parseAsyncJobId(contentLocation);
165
+ if (!jobId) {
166
+ throw new OystehrSdkError({
167
+ message: `Could not parse async job id from Content-Location: ${contentLocation}`,
168
+ code: 500,
169
+ });
170
+ }
171
+
172
+ return { jobId, contentLocation, mode };
173
+ }
174
+
175
+ protected async fetchAsyncJobStatus<T extends FhirResource>(
176
+ jobId: string,
177
+ request?: OystehrClientRequest
178
+ ): Promise<FhirAsyncJobStatus<T>> {
179
+ const raw = await this.fhirRequest(`/async-job/${jobId}`, 'GET')(
180
+ {},
181
+ {
182
+ ...request,
183
+ rawResponse: true,
184
+ }
185
+ );
186
+
187
+ if (raw.status === 202) {
188
+ return {
189
+ status: 202,
190
+ xProgress: this.readHeader(raw.headers, 'x-progress'),
191
+ retryAfter: this.readHeader(raw.headers, 'retry-after'),
192
+ };
193
+ }
194
+
195
+ if (raw.status === 410) {
196
+ return { status: 410 };
197
+ }
198
+
199
+ if (raw.status === 404) {
200
+ return { status: 404 };
201
+ }
202
+
203
+ if (raw.status === 200) {
204
+ if (this.isBulkManifest(raw.body)) {
205
+ return {
206
+ status: 200,
207
+ mode: 'bulk',
208
+ manifest: raw.body,
209
+ };
210
+ }
211
+
212
+ const bundle = raw.body as FhirAsyncCompletionBundle<T>;
213
+ if (bundle?.resourceType === 'Bundle' && bundle?.type === 'batch-response') {
214
+ const entry0 = bundle.entry?.[0];
215
+ const interactionStatus = entry0?.response?.status;
216
+ const resource = entry0?.resource;
217
+ const outcome = entry0?.response?.outcome;
218
+ return {
219
+ status: 200,
220
+ mode: 'bundle',
221
+ bundle,
222
+ interactionStatus,
223
+ resource,
224
+ outcome,
225
+ };
226
+ }
227
+
228
+ return {
229
+ status: 200,
230
+ body: raw.body,
231
+ };
232
+ }
233
+
234
+ return {
235
+ status: raw.status,
236
+ body: raw.body,
237
+ };
238
+ }
239
+
240
+ private readHeader(headers: Record<string, string>, name: string): string | undefined {
241
+ const direct = headers[name];
242
+ if (direct != null) {
243
+ return direct;
244
+ }
245
+
246
+ const lower = name.toLowerCase();
247
+ const key = Object.keys(headers).find((h) => h.toLowerCase() === lower);
248
+ return key ? headers[key] : undefined;
249
+ }
250
+
251
+ private parseAsyncJobId(contentLocation: string): string | undefined {
252
+ const segments = contentLocation.split('/').filter(Boolean);
253
+ const asyncJobIndex = segments.lastIndexOf('async-job');
254
+ if (asyncJobIndex < 0 || asyncJobIndex + 1 >= segments.length) {
255
+ return undefined;
256
+ }
257
+
258
+ return segments[asyncJobIndex + 1];
259
+ }
260
+
261
+ private appendBulkOutputFormat(path: string): string {
262
+ const separator = path.includes('?') ? '&' : '?';
263
+ return `${path}${separator}_outputFormat=${encodeURIComponent('application/fhir+ndjson')}`;
264
+ }
265
+
266
+ private isBulkManifest(body: unknown): body is FhirAsyncBulkManifest {
267
+ if (body == null || typeof body !== 'object') {
268
+ return false;
269
+ }
270
+
271
+ const maybe = body as Record<string, unknown>;
272
+ return (
273
+ typeof maybe.transactionTime === 'string' &&
274
+ typeof maybe.request === 'string' &&
275
+ typeof maybe.requiresAccessToken === 'boolean' &&
276
+ Array.isArray(maybe.output) &&
277
+ Array.isArray(maybe.error)
278
+ );
279
+ }
103
280
  }
104
281
 
105
282
  export type FetcherError = { message: string; code: number };
106
283
  export type FetcherResponse = any;
284
+ export type RawFetcherResponse<T = unknown> = {
285
+ status: number;
286
+ headers: Record<string, string>;
287
+ body: T | null;
288
+ };
107
289
  export type FetcherFunction = (
108
290
  params?: Record<string, any> | [any] | InternalClientRequest,
109
- request?: InternalClientRequest
291
+ request?: InternalClientRequest,
292
+ requestMode?: FhirResponseMode
110
293
  ) => Promise<FetcherResponse>;
111
294
 
112
295
  function isInternalClientRequest(request: Record<string, any>): request is InternalClientRequest {
@@ -115,10 +298,19 @@ function isInternalClientRequest(request: Record<string, any>): request is Inter
115
298
  ('projectId' in request && uuidValidate(request.projectId)) ||
116
299
  ('contentType' in request && request.contentType?.split('/').length === 2) ||
117
300
  'requestId' in request ||
118
- ('ifMatch' in request && request.ifMatch.startsWith('W/"'))
301
+ ('ifMatch' in request && request.ifMatch.startsWith('W/"')) ||
302
+ 'mode' in request ||
303
+ 'rawResponse' in request
119
304
  );
120
305
  }
121
306
 
307
+ function getPreferHeaderFromMode(mode: FhirResponseMode | undefined): string | undefined {
308
+ if (mode === 'async-bundle' || mode === 'async-bulk') {
309
+ return 'respond-async';
310
+ }
311
+ return undefined;
312
+ }
313
+
122
314
  /**
123
315
  * Parse XML response in format <response><status>...</status><output>...</output></response>
124
316
  */
@@ -151,7 +343,8 @@ function fetcher(
151
343
  ): FetcherFunction {
152
344
  return async (
153
345
  params?: Record<string, unknown> | [any] | InternalClientRequest,
154
- request?: InternalClientRequest
346
+ request?: InternalClientRequest,
347
+ requestMode?: FhirResponseMode
155
348
  ): Promise<FetcherResponse> => {
156
349
  // this function supports multiple signatures. fetcher(baseUrl, path, method)(params, request) or fetcher(baseUrl, path, method)(request)
157
350
  // or fetcher(baseUrl, path, method)(params) or fetcher(baseUrl, path, method)(). the types for this are handled by Client<Path, Methods>
@@ -214,6 +407,8 @@ function fetcher(
214
407
  requestId: requestCtx?.requestId,
215
408
  });
216
409
 
410
+ const preferHeader = getPreferHeaderFromMode(requestMode);
411
+
217
412
  const headers: Record<string, string> = Object.assign(
218
413
  projectId
219
414
  ? {
@@ -224,6 +419,7 @@ function fetcher(
224
419
  {
225
420
  'content-type': requestCtx?.contentType ?? 'application/json',
226
421
  },
422
+ preferHeader ? { Prefer: preferHeader } : {},
227
423
  accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
228
424
  requestCtx?.ifMatch ? { 'If-Match': requestCtx.ifMatch } : {},
229
425
  { 'x-oystehr-request-id': requestCtx?.requestId }
@@ -301,6 +497,17 @@ function fetcher(
301
497
  url,
302
498
  requestId: requestCtx?.requestId,
303
499
  });
500
+ if (requestCtx?.rawResponse) {
501
+ const headersRecord: Record<string, string> = {};
502
+ response.headers.forEach((value, key) => {
503
+ headersRecord[key] = value;
504
+ });
505
+ return {
506
+ status: response.status,
507
+ headers: headersRecord,
508
+ body: responseJson ?? responseBody,
509
+ } as RawFetcherResponse;
510
+ }
304
511
  const isError = !response.ok || response.status >= 400;
305
512
  if (isError) {
306
513
  const errObj = {