• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

smart-on-fhir / client-js / 15307687814

28 May 2025 06:23PM UTC coverage: 94.557% (-1.6%) from 96.167%
15307687814

push

github

vlad-ignatov
Build and docs

515 of 574 branches covered (89.72%)

Branch coverage included in aggregate %.

927 of 951 relevant lines covered (97.48%)

133.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.98
/src/Client.ts
1
import {
3✔
2
    absolute,
3
    debug as _debug,
4
    getPath,
5
    jwtDecode,
6
    makeArray,
7
    request,
8
    byCode,
9
    byCodes,
10
    units,
11
    getPatientParam,
12
    fetchConformanceStatement,
13
    getAccessTokenExpiration,
14
    assert
15
} from "./lib";
16

17
import str from "./strings";
3✔
18
import { SMART_KEY, patientCompartment } from "./settings";
3✔
19
import HttpError from "./HttpError";
20
import BrowserAdapter from "./adapters/BrowserAdapter";
21
import { fhirclient } from "./types";
22
import FhirClient from "./FhirClient";
3✔
23

24
// $lab:coverage:off$
25
// @ts-ignore
26
const { Response } = typeof FHIRCLIENT_PURE !== "undefined" ? window : require("cross-fetch");
3!
27
// $lab:coverage:on$
28

29
const debug = _debug.extend("client");
3✔
30

31
/**
32
 * Adds patient context to requestOptions object to be used with [[Client.request]]
33
 * @param requestOptions Can be a string URL (relative to the serviceUrl), or an
34
 * object which will be passed to fetch()
35
 * @param client Current FHIR client object containing patient context
36
 * @return requestOptions object contextualized to current patient
37
 */
38
async function contextualize(
39
    requestOptions: string | URL | fhirclient.RequestOptions,
40
    client: Client
41
): Promise<fhirclient.RequestOptions>
42
{
43
    const base = absolute("/", client.state.serverUrl);
66✔
44

45
    async function contextualURL(_url: URL) {
46
        const resourceType = _url.pathname.split("/").pop();
66✔
47
        assert(resourceType, `Invalid url "${_url}"`);
66✔
48
        assert(patientCompartment.indexOf(resourceType) > -1, `Cannot filter "${resourceType}" resources by patient`);
60✔
49
        const conformance = await fetchConformanceStatement(client.state.serverUrl);
54✔
50
        const searchParam = getPatientParam(conformance, resourceType);
54✔
51
        _url.searchParams.set(searchParam, client.patient.id as string);
36✔
52
        return _url.href;
36✔
53
    }
54

55
    if (typeof requestOptions == "string" || requestOptions instanceof URL) {
66✔
56
        return { url: await contextualURL(new URL(requestOptions + "", base)) };
48✔
57
    }
58

59
    requestOptions.url = await contextualURL(new URL(requestOptions.url + "", base));
18✔
60
    return requestOptions;
18✔
61
}
62

63
/**
64
 * This is a FHIR client that is returned to you from the `ready()` call of the
65
 * **SMART API**. You can also create it yourself if needed:
66
 *
67
 * ```js
68
 * // BROWSER
69
 * const client = FHIR.client("https://r4.smarthealthit.org");
70
 *
71
 * // SERVER
72
 * const client = smart(req, res).client("https://r4.smarthealthit.org");
73
 * ```
74
 */
75
export default class Client extends FhirClient
3✔
76
{
77
    /**
78
     * The state of the client instance is an object with various properties.
79
     * It contains some details about how the client has been authorized and
80
     * determines the behavior of the client instance. This state is persisted
81
     * in `SessionStorage` in browsers or in request session on the servers.
82
     */
83
    readonly state: fhirclient.ClientState;
84

85
    /**
86
     * The adapter to use to connect to the current environment. Currently we have:
87
     * - BrowserAdapter - for browsers
88
     * - NodeAdapter - for Express or vanilla NodeJS servers
89
     * - HapiAdapter - for HAPI NodeJS servers
90
     */
91
    readonly environment: fhirclient.Adapter;
92

93
    /**
94
     * A SMART app is typically associated with a patient. This is a namespace
95
     * for the patient-related functionality of the client.
96
     */
97
    readonly patient: {
98

99
        /**
100
         * The ID of the current patient or `null` if there is no current patient
101
         */
102
        id: string | null
103

104
        /**
105
         * A method to fetch the current patient resource from the FHIR server.
106
         * If there is no patient context, it will reject with an error.
107
         * @param {fhirclient.FetchOptions} [requestOptions] Any options to pass to the `fetch` call.
108
         * @category Request
109
         */
110
        read: fhirclient.RequestFunction<fhirclient.FHIR.Patient>
111
        
112
        /**
113
         * This is similar to [[request]] but it makes requests in the
114
         * context of the current patient. For example, instead of doing
115
         * ```js
116
         * client.request("Observation?patient=" + client.patient.id)
117
         * ```
118
         * you can do
119
         * ```js
120
         * client.patient.request("Observation")
121
         * ```
122
         * The return type depends on the arguments. Typically it will be the
123
         * response payload JSON object. Can also be a string or the `Response`
124
         * object itself if we have received a non-json result, which allows us
125
         * to handle even binary responses. Can also be a [[CombinedFetchResult]]
126
         * object if the `requestOptions.includeResponse`s has been set to true.
127
         * @category Request
128
         */
129
        request: <R = fhirclient.FetchResult>(
130
            requestOptions: string|URL|fhirclient.RequestOptions,
131
            fhirOptions?: fhirclient.FhirOptions
132
        ) => Promise<R>
133

134
        /**
135
         * This is the FhirJS Patient API. It will ONLY exist if the `Client`
136
         * instance is "connected" to FhirJS.
137
         */
138
        api?: Record<string, any>
139
    };
140

141
    /**
142
     * The client may be associated with a specific encounter, if the scopes
143
     * permit that and if the back-end server supports that. This is a namespace
144
     * for encounter-related functionality.
145
     */
146
    readonly encounter: {
147

148
        /**
149
         * The ID of the current encounter or `null` if there is no current
150
         * encounter
151
         */
152
        id: string | null
153

154
        /**
155
         * A method to fetch the current encounter resource from the FHIR server.
156
         * If there is no encounter context, it will reject with an error.
157
         * @param [requestOptions] Any options to pass to the `fetch` call.
158
         * @category Request
159
         */
160
        read: fhirclient.RequestFunction<fhirclient.FHIR.Encounter>
161
    };
162

163
    /**
164
     * The client may be associated with a specific user, if the scopes
165
     * permit that. This is a namespace for user-related functionality.
166
     */
167
    readonly user: {
168

169
        /**
170
         * The ID of the current user or `null` if there is no current user
171
         */
172
        id: string | null
173

174
        /**
175
         * A method to fetch the current user resource from the FHIR server.
176
         * If there is no user context, it will reject with an error.
177
         * @param [requestOptions] Any options to pass to the `fetch` call.
178
         * @category Request
179
         */
180
        read: fhirclient.RequestFunction<
181
            fhirclient.FHIR.Patient |
182
            fhirclient.FHIR.Practitioner |
183
            fhirclient.FHIR.RelatedPerson
184
        >
185

186
        /**
187
         * Returns the profile of the logged_in user (if any), or null if the
188
         * user is not available. This is a string having the shape
189
         * `{user type}/{user id}`. For example `Practitioner/abc` or
190
         * `Patient/xyz`.
191
         * @alias client.getFhirUser()
192
         */
193
        fhirUser: string | null
194

195
        /**
196
         * Returns the type of the logged-in user or null. The result can be
197
         * `Practitioner`, `Patient` or `RelatedPerson`.
198
         * @alias client.getUserType()
199
         */
200
        resourceType: string | null
201
    };
202

203
    /**
204
     * The [FhirJS](https://github.com/FHIR/fhir.js/blob/master/README.md) API.
205
     * **NOTE:** This will only be available if `fhir.js` is used. Otherwise it
206
     * will be `undefined`.
207
     */
208
    api: Record<string, any> | undefined;
209

210
    /**
211
     * Refers to the refresh task while it is being performed.
212
     * @see [[refresh]]
213
     */
214
    private _refreshTask: Promise<any> | null;
215

216
    /**
217
     * Validates the parameters, creates an instance and tries to connect it to
218
     * FhirJS, if one is available globally.
219
     */
220
    constructor(environment: fhirclient.Adapter, state: fhirclient.ClientState | string)
221
    {
222
        const _state = typeof state == "string" ? { serverUrl: state } : state;
750✔
223
        
224
        // Valid serverUrl is required!
225
        assert(
750✔
226
            _state.serverUrl && _state.serverUrl.match(/https?:\/\/.+/),
1,491✔
227
            "A \"serverUrl\" option is required and must begin with \"http(s)\""
228
        );
229
        
230
        super(_state.serverUrl)
738✔
231

232
        this.state = _state;
738✔
233
        this.environment = environment;
738✔
234
        this._refreshTask = null;
738✔
235

236
        const client = this;
738✔
237

238
        // patient api ---------------------------------------------------------
239
        this.patient = {
738✔
240
            get id() { return client.getPatientId(); },
132✔
241
            read: (requestOptions) => {
242
                const id = this.patient.id;
12✔
243
                return id ?
12✔
244
                    this.request({ ...requestOptions, url: `Patient/${id}` }) :
245
                    Promise.reject(new Error("Patient is not available"));
246
            },
247
            request: (requestOptions, fhirOptions = {}) => {
72✔
248
                if (this.patient.id) {
72✔
249
                    return (async () => {
66✔
250
                        const options = await contextualize(requestOptions, this);
66✔
251
                        return this.request(options, fhirOptions);
36✔
252
                    })();
253
                } else {
254
                    return Promise.reject(new Error("Patient is not available"));
6✔
255
                }
256
            }
257
        };
258

259
        // encounter api -------------------------------------------------------
260
        this.encounter = {
738✔
261
            get id() { return client.getEncounterId(); },
36✔
262
            read: requestOptions => {
263
                const id = this.encounter.id;
24✔
264
                return id ?
24✔
265
                    this.request({ ...requestOptions, url: `Encounter/${id}` }) :
266
                    Promise.reject(new Error("Encounter is not available"));
267
            }
268
        };
269

270
        // user api ------------------------------------------------------------
271
        this.user = {
738✔
272
            get fhirUser() { return client.getFhirUser(); },
18✔
273
            get id() { return client.getUserId(); },
24✔
274
            get resourceType() { return client.getUserType(); },
12✔
275
            read: requestOptions => {
276
                const fhirUser = this.user.fhirUser;
18✔
277
                return fhirUser ?
18✔
278
                    this.request({ ...requestOptions, url: fhirUser }) :
279
                    Promise.reject(new Error("User is not available"));
280
            }
281
        };
282

283
        // fhir.js api (attached automatically in browser)
284
        // ---------------------------------------------------------------------
285
        this.connect((environment as BrowserAdapter).fhir);
738✔
286
    }
287

288
    /**
289
     * This method is used to make the "link" between the `fhirclient` and the
290
     * `fhir.js`, if one is available.
291
     * **Note:** This is called by the constructor. If fhir.js is available in
292
     * the global scope as `fhir`, it will automatically be linked to any [[Client]]
293
     * instance. You should only use this method to connect to `fhir.js` which
294
     * is not global.
295
     */
296
    connect(fhirJs?: (options: Record<string, any>) => Record<string, any>): Client
297
    {
298
        if (typeof fhirJs == "function") {
756✔
299
            const options: Record<string, any> = {
21✔
300
                baseUrl: this.state.serverUrl.replace(/\/$/, "")
301
            };
302

303
            const accessToken = this.getState("tokenResponse.access_token");
21✔
304
            if (accessToken) {
21✔
305
                options.auth = { token: accessToken };
3✔
306
            }
307
            else {
308
                const { username, password } = this.state;
18✔
309
                if (username && password) {
18✔
310
                    options.auth = {
9✔
311
                        user: username,
312
                        pass: password
313
                    };
314
                }
315
            }
316
            this.api = fhirJs(options);
21✔
317

318
            const patientId = this.getState("tokenResponse.patient");
21✔
319
            if (patientId) {
21✔
320
                this.patient.api = fhirJs({
6✔
321
                    ...options,
322
                    patient: patientId
323
                });
324
            }
325
        }
326
        return this;
756✔
327
    }
328

329
    /**
330
     * Returns the ID of the selected patient or null. You should have requested
331
     * "launch/patient" scope. Otherwise this will return null.
332
     */
333
    getPatientId(): string | null
334
    {
335
        const tokenResponse = this.state.tokenResponse;
180✔
336
        if (tokenResponse) {
180✔
337
            // We have been authorized against this server but we don't know
338
            // the patient. This should be a scope issue.
339
            if (!tokenResponse.patient) {
168✔
340
                if (!(this.state.scope || "").match(/\blaunch(\/patient)?\b/)) {
24✔
341
                    debug(str.noScopeForId, "patient", "patient");
18✔
342
                }
343
                else {
344
                    // The server should have returned the patient!
345
                    debug("The ID of the selected patient is not available. Please check if your server supports that.");
6✔
346
                }
347
                return null;
24✔
348
            }
349
            return tokenResponse.patient;
144✔
350
        }
351

352
        if (this.state.authorizeUri) {
12✔
353
            debug(str.noIfNoAuth, "the ID of the selected patient");
6✔
354
        }
355
        else {
356
            debug(str.noFreeContext, "selected patient");
6✔
357
        }
358
        return null;
12✔
359
    }
360

361
    /**
362
     * Returns the ID of the selected encounter or null. You should have
363
     * requested "launch/encounter" scope. Otherwise this will return null.
364
     * Note that not all servers support the "launch/encounter" scope so this
365
     * will be null if they don't.
366
     */
367
    getEncounterId(): string | null
368
    {
369
        const tokenResponse = this.state.tokenResponse;
84✔
370
        if (tokenResponse) {
84✔
371
            // We have been authorized against this server but we don't know
372
            // the encounter. This should be a scope issue.
373
            if (!tokenResponse.encounter) {
72✔
374
                if (!(this.state.scope || "").match(/\blaunch(\/encounter)?\b/)) {
18✔
375
                    debug(str.noScopeForId, "encounter", "encounter");
12✔
376
                }
377
                else {
378
                    // The server should have returned the encounter!
379
                    debug("The ID of the selected encounter is not available. Please check if your server supports that, and that the selected patient has any recorded encounters.");
6✔
380
                }
381
                return null;
18✔
382
            }
383
            return tokenResponse.encounter;
54✔
384
        }
385

386
        if (this.state.authorizeUri) {
12✔
387
            debug(str.noIfNoAuth, "the ID of the selected encounter");
6✔
388
        }
389
        else {
390
            debug(str.noFreeContext, "selected encounter");
6✔
391
        }
392
        return null;
12✔
393
    }
394

395
    /**
396
     * Returns the (decoded) id_token if any. You need to request "openid" and
397
     * "profile" scopes if you need to receive an id_token (if you need to know
398
     * who the logged-in user is).
399
     */
400
    getIdToken(): fhirclient.IDToken | null
401
    {
402
        const tokenResponse = this.state.tokenResponse;
180✔
403
        if (tokenResponse) {
180✔
404
            const idToken = tokenResponse.id_token;
168✔
405
            const scope = this.state.scope || "";
168✔
406

407
            // We have been authorized against this server but we don't have
408
            // the id_token. This should be a scope issue.
409
            if (!idToken) {
168✔
410
                const hasOpenid   = scope.match(/\bopenid\b/);
42✔
411
                const hasProfile  = scope.match(/\bprofile\b/);
42✔
412
                const hasFhirUser = scope.match(/\bfhirUser\b/);
42✔
413
                if (!hasOpenid || !(hasFhirUser || hasProfile)) {
42!
414
                    debug(
36✔
415
                        "You are trying to get the id_token but you are not " +
416
                        "using the right scopes. Please add 'openid' and " +
417
                        "'fhirUser' or 'profile' to the scopes you are " +
418
                        "requesting."
419
                    );
420
                }
421
                else {
422
                    // The server should have returned the id_token!
423
                    debug("The id_token is not available. Please check if your server supports that.");
6✔
424
                }
425
                return null;
42✔
426
            }
427
            return jwtDecode(idToken, this.environment) as fhirclient.IDToken;
126✔
428
        }
429
        if (this.state.authorizeUri) {
12✔
430
            debug(str.noIfNoAuth, "the id_token");
6✔
431
        }
432
        else {
433
            debug(str.noFreeContext, "id_token");
6✔
434
        }
435
        return null;
12✔
436
    }
437

438
    /**
439
     * Returns the profile of the logged_in user (if any). This is a string
440
     * having the following shape `"{user type}/{user id}"`. For example:
441
     * `"Practitioner/abc"` or `"Patient/xyz"`.
442
     */
443
    getFhirUser(): string | null
444
    {
445
        const idToken = this.getIdToken();
156✔
446
        if (idToken) {
156✔
447
            // Epic may return a full url
448
            // @see https://github.com/smart-on-fhir/client-js/issues/105
449
            if (idToken.fhirUser) {
126✔
450
                return idToken.fhirUser.split("/").slice(-2).join("/");
120✔
451
            }
452
            return idToken.profile
6✔
453
        }
454
        return null;
30✔
455
    }
456

457
    /**
458
     * Returns the user ID or null.
459
     */
460
    getUserId(): string | null
461
    {
462
        const profile = this.getFhirUser();
60✔
463
        if (profile) {
60✔
464
            return profile.split("/")[1];
48✔
465
        }
466
        return null;
12✔
467
    }
468

469
    /**
470
     * Returns the type of the logged-in user or null. The result can be
471
     * "Practitioner", "Patient" or "RelatedPerson".
472
     */
473
    getUserType(): string | null
474
    {
475
        const profile = this.getFhirUser();
48✔
476
        if (profile) {
48✔
477
            return profile.split("/")[0];
42✔
478
        }
479
        return null;
6✔
480
    }
481

482
    /**
483
     * Builds and returns the value of the `Authorization` header that can be
484
     * sent to the FHIR server
485
     */
486
    getAuthorizationHeader(): string | null
487
    {
488
        const accessToken = this.getState("tokenResponse.access_token");
495✔
489
        if (accessToken) {
495✔
490
            return "Bearer " + accessToken;
39✔
491
        }
492
        const { username, password } = this.state;
456✔
493
        if (username && password) {
456✔
494
            return "Basic " + this.environment.btoa(username + ":" + password);
6✔
495
        }
496
        return null;
450✔
497
    }
498

499
    /**
500
     * Used internally to clear the state of the instance and the state in the
501
     * associated storage.
502
     */
503
    private async _clearState() {
504
        const storage = this.environment.getStorage();
18✔
505
        const key = await storage.get(SMART_KEY);
18✔
506
        if (key) {
12✔
507
            await storage.unset(key);
6✔
508
        }
509
        await storage.unset(SMART_KEY);
12✔
510
        this.state.tokenResponse = {};
12✔
511
    }
512

513
    /**
514
     * @param requestOptions Can be a string URL (relative to the serviceUrl),
515
     * or an object which will be passed to fetch()
516
     * @param fhirOptions Additional options to control the behavior
517
     * @param _resolvedRefs DO NOT USE! Used internally.
518
     * @category Request
519
     */
520
    async request<T = any>(
521
        requestOptions: string|URL|fhirclient.RequestOptions,
522
        fhirOptions: fhirclient.FhirOptions = {},
132✔
523
        _resolvedRefs: fhirclient.JsonObject = {}
366✔
524
    ): Promise<T>
525
    {
526
        const debugRequest = _debug.extend("client:request");
480✔
527
        assert(requestOptions, "request requires an url or request options as argument");
480✔
528

529
        // url -----------------------------------------------------------------
530
        let url: string;
531
        if (typeof requestOptions == "string" || requestOptions instanceof URL) {
477✔
532
            url = String(requestOptions);
249✔
533
            requestOptions = {} as fhirclient.RequestOptions;
249✔
534
        }
535
        else {
536
            url = String(requestOptions.url);
228✔
537
        }
538

539
        url = absolute(url, this.state.serverUrl);
477✔
540

541
        const options = {
477✔
542
            graph: fhirOptions.graph !== false,
543
            flat : !!fhirOptions.flat,
544
            pageLimit: fhirOptions.pageLimit ?? 1,
1,431✔
545
            resolveReferences: makeArray(fhirOptions.resolveReferences || []) as string[],
726✔
546
            useRefreshToken: fhirOptions.useRefreshToken !== false,
547
            onPage: typeof fhirOptions.onPage == "function" ?
477✔
548
                fhirOptions.onPage as (
549
                    data: fhirclient.JsonObject | fhirclient.JsonObject[],
550
                    references?: fhirclient.JsonObject | undefined) => any :
551
                undefined
552
        };
553

554
        const signal = (requestOptions as RequestInit).signal || undefined;
477✔
555

556
        // Refresh the access token if needed
557
        if (options.useRefreshToken) {
477✔
558
            await this.refreshIfNeeded({ signal })
471✔
559
        }
560

561
        // Add the Authorization header now, after the access token might
562
        // have been updated
563
        const authHeader = this.getAuthorizationHeader();
471✔
564
        if (authHeader) {
471✔
565
            requestOptions.headers = {
33✔
566
                ...requestOptions.headers,
567
                authorization: authHeader
568
            };
569
        }
570

571
        debugRequest("%s, options: %O, fhirOptions: %O", url, requestOptions, options);
471✔
572

573
        let response: Response | undefined;
574

575
        return super.fhirRequest<fhirclient.FetchResult>(url, requestOptions).then(result => {
471✔
576
            if ((requestOptions as fhirclient.RequestOptions).includeResponse) {
411✔
577
                response = (result as fhirclient.CombinedFetchResult).response;
30✔
578
                return (result as fhirclient.CombinedFetchResult).body;
30✔
579
            }
580
            return result;
381✔
581
        })
582

583
        // Handle 401 ----------------------------------------------------------
584
        .catch(async (error: HttpError) => {
585
            if (error.status == 401) {
60✔
586

587
                // !accessToken -> not authorized -> No session. Need to launch.
588
                if (!this.getState("tokenResponse.access_token")) {
18✔
589
                    error.message += "\nThis app cannot be accessed directly. Please launch it as SMART app!";
6✔
590
                    throw error;
6✔
591
                }
592

593
                // auto-refresh not enabled and Session expired.
594
                // Need to re-launch. Clear state to start over!
595
                if (!options.useRefreshToken) {
12✔
596
                    debugRequest("Your session has expired and the useRefreshToken option is set to false. Please re-launch the app.");
6✔
597
                    await this._clearState();
6✔
598
                    error.message += "\n" + str.expired;
3✔
599
                    throw error;
3✔
600
                }
601

602
                // In rare cases we may have a valid access token and a refresh
603
                // token and the request might still fail with 401 just because
604
                // the access token has just been revoked.
605

606
                // otherwise -> auto-refresh failed. Session expired.
607
                // Need to re-launch. Clear state to start over!
608
                debugRequest("Auto-refresh failed! Please re-launch the app.");
6✔
609
                await this._clearState();
6✔
610
                error.message += "\n" + str.expired;
3✔
611
                throw error;
3✔
612
            }
613
            throw error;
42✔
614
        })
615

616
        // Handle 403 ----------------------------------------------------------
617
        .catch((error: HttpError) => {
618
            if (error.status == 403) {
60✔
619
                debugRequest("Permission denied! Please make sure that you have requested the proper scopes.");
6✔
620
            }
621
            throw error;
60✔
622
        })
623

624
        .then(async (data: any) => {
625

626
            // At this point we don't know what `data` actually is!
627
            // We might get an empty or falsy result. If so return it as is
628
            // Also handle raw responses
629
            if (!data || typeof data == "string" || data instanceof Response) {
411✔
630
                if ((requestOptions as fhirclient.FetchOptions).includeResponse) {
36!
631
                    return {
×
632
                        body: data,
633
                        response
634
                    }
635
                }
636
                return data;
36✔
637
            }
638
            
639
            // Resolve References ----------------------------------------------
640
            await this.fetchReferences(
375✔
641
                data as any,
642
                options.resolveReferences,
643
                options.graph,
644
                _resolvedRefs,
645
                requestOptions as fhirclient.RequestOptions
646
            );
647

648
            return Promise.resolve(data)
369✔
649

650
            // Pagination ------------------------------------------------------
651
            .then(async _data => {
652
                if (_data && _data.resourceType == "Bundle") {
369✔
653
                    const links = (_data.link || []) as fhirclient.FHIR.BundleLink[];
240✔
654

655
                    if (options.flat) {
240✔
656
                        _data = (_data.entry || []).map(
78!
657
                            (entry: fhirclient.FHIR.BundleEntry) => entry.resource
126✔
658
                        );
659
                    }
660

661
                    if (options.onPage) {
240✔
662
                        await options.onPage(_data, { ..._resolvedRefs });
108✔
663
                    }
664

665
                    if (--options.pageLimit) {
228✔
666
                        const next = links.find(l => l.relation == "next");
192✔
667
                        _data = makeArray(_data);
192✔
668
                        if (next && next.url) {
192✔
669
                            const nextPage = await this.request(
114✔
670
                                {
671
                                    url: next.url,
672

673
                                    // Aborting the main request (even after it is complete)
674
                                    // must propagate to any child requests and abort them!
675
                                    // To do so, just pass the same AbortSignal if one is
676
                                    // provided.
677
                                    signal
678
                                },
679
                                options,
680
                                _resolvedRefs
681
                            );
682

683
                            if (options.onPage) {
102✔
684
                                return null;
48✔
685
                            }
686

687
                            if (options.resolveReferences.length) {
54✔
688
                                Object.assign(_resolvedRefs, nextPage.references);
30✔
689
                                return _data.concat(makeArray(nextPage.data || nextPage));
30✔
690
                            }
691
                            return _data.concat(makeArray(nextPage));
24✔
692
                        }
693
                    }
694
                }
695
                return _data;
243✔
696
            })
697

698
            // Finalize --------------------------------------------------------
699
            .then(_data => {
700
                if (options.graph) {
345✔
701
                    _resolvedRefs = {};
285✔
702
                }
703
                else if (!options.onPage && options.resolveReferences.length) {
60✔
704
                    return {
30✔
705
                        data: _data,
706
                        references: _resolvedRefs
707
                    };
708
                }
709
                return _data;
315✔
710
            })
711
            .then(_data => {
712
                if ((requestOptions as fhirclient.FetchOptions).includeResponse) {
345✔
713
                    return {
30✔
714
                        body: _data,
715
                        response
716
                    }
717
                }
718
                return _data;
315✔
719
            });
720
        });
721
    }
722

723
    /**
724
     * Checks if access token and refresh token are present. If they are, and if
725
     * the access token is expired or is about to expire in the next 10 seconds,
726
     * calls `this.refresh()` to obtain new access token.
727
     * @param requestOptions Any options to pass to the fetch call. Most of them
728
     * will be overridden, bit it might still be useful for passing additional
729
     * request options or an abort signal.
730
     * @category Request
731
     */
732
    refreshIfNeeded(requestOptions: RequestInit = {}): Promise<fhirclient.ClientState>
×
733
    {
734
        const accessToken  = this.getState("tokenResponse.access_token");
471✔
735
        const refreshToken = this.getState("tokenResponse.refresh_token");
471✔
736
        const expiresAt    = this.state.expiresAt || 0;
471✔
737

738
        if (accessToken && refreshToken && expiresAt - 10 < Date.now() / 1000) {
471✔
739
            return this.refresh(requestOptions);
21✔
740
        }
741

742
        return Promise.resolve(this.state);
450✔
743
    }
744

745
    /**
746
     * Use the refresh token to obtain new access token. If the refresh token is
747
     * expired (or this fails for any other reason) it will be deleted from the
748
     * state, so that we don't enter into loops trying to re-authorize.
749
     *
750
     * This method is typically called internally from [[request]] if
751
     * certain request fails with 401.
752
     *
753
     * @param requestOptions Any options to pass to the fetch call. Most of them
754
     * will be overridden, bit it might still be useful for passing additional
755
     * request options or an abort signal.
756
     * @category Request
757
     */
758
    refresh(requestOptions: RequestInit = {}): Promise<fhirclient.ClientState>
30✔
759
    {
760
        const debugRefresh = _debug.extend("client:refresh");
57✔
761
        debugRefresh("Attempting to refresh with refresh_token...");
57✔
762

763
        const refreshToken = this.state?.tokenResponse?.refresh_token;
57!
764
        assert(refreshToken, "Unable to refresh. No refresh_token found.");
57✔
765

766
        const tokenUri = this.state.tokenUri;
54✔
767
        assert(tokenUri, "Unable to refresh. No tokenUri found.");
54✔
768

769
        const scopes = this.getState("tokenResponse.scope") || "";
45✔
770
        const hasOfflineAccess = scopes.search(/\boffline_access\b/) > -1;
45✔
771
        const hasOnlineAccess = scopes.search(/\bonline_access\b/) > -1;
45✔
772
        assert(hasOfflineAccess || hasOnlineAccess, "Unable to refresh. No offline_access or online_access scope found.");
45✔
773

774
        // This method is typically called internally from `request` if certain
775
        // request fails with 401. However, clients will often run multiple
776
        // requests in parallel which may result in multiple refresh calls.
777
        // To avoid that, we keep a reference to the current refresh task (if any).
778
        if (!this._refreshTask) {
39✔
779
            let body = `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`;
36✔
780
            if (this.environment.options.refreshTokenWithClientId) {
36!
781
                body += `&client_id=${this.state.clientId}`;
×
782
            }
783
            const refreshRequestOptions = {
36✔
784
                credentials: this.environment.options.refreshTokenWithCredentials || "same-origin",
42✔
785
                ...requestOptions,
786
                method : "POST",
787
                mode   : "cors" as RequestMode,
788
                headers: {
789
                    ...(requestOptions.headers || {}),
66✔
790
                    "content-type": "application/x-www-form-urlencoded"
791
                },
792
                body: body
793
            };
794

795
            // custom authorization header can be passed on manual calls
796
            if (!("authorization" in refreshRequestOptions.headers)) {
36✔
797
                const { clientSecret, clientId } = this.state;
33✔
798
                if (clientSecret) {
33✔
799
                    // @ts-ignore
800
                    refreshRequestOptions.headers.authorization = "Basic " + this.environment.btoa(
3✔
801
                        clientId + ":" + clientSecret
802
                    );
803
                }
804
            }
805

806
            this._refreshTask = request<fhirclient.TokenResponse>(tokenUri, refreshRequestOptions)
36✔
807
            .then(data => {
808
                assert(data.access_token, "No access token received");
36✔
809
                debugRefresh("Received new access token response %O", data);
30✔
810
                this.state.tokenResponse = { ...this.state.tokenResponse, ...data };
30✔
811
                this.state.expiresAt = getAccessTokenExpiration(data, this.environment);
30✔
812
                return this.state;
30✔
813
            })
814
            .catch((error: Error) => {
815
                if (this.state?.tokenResponse?.refresh_token) {
6!
816
                    debugRefresh("Deleting the expired or invalid refresh token.");
6✔
817
                    delete this.state.tokenResponse.refresh_token;
6✔
818
                }
819
                throw error;
6✔
820
            })
821
            .finally(() => {
822
                this._refreshTask = null;
36✔
823
                const key = this.state.key;
36✔
824
                if (key) {
36✔
825
                    this.environment.getStorage().set(key, this.state);
18✔
826
                } else {
827
                    debugRefresh("No 'key' found in Clint.state. Cannot persist the instance.");
18✔
828
                }
829
            });
830
        }
831

832
        return this._refreshTask;
39✔
833
    }
834

835
    // utils -------------------------------------------------------------------
836

837
    /**
838
     * Groups the observations by code. Returns a map that will look like:
839
     * ```js
840
     * const map = client.byCodes(observations, "code");
841
     * // map = {
842
     * //     "55284-4": [ observation1, observation2 ],
843
     * //     "6082-2": [ observation3 ]
844
     * // }
845
     * ```
846
     * @param observations Array of observations
847
     * @param property The name of a CodeableConcept property to group by
848
     * @todo This should be deprecated and moved elsewhere. One should not have
849
     * to obtain an instance of [[Client]] just to use utility functions like this.
850
     * @deprecated
851
     * @category Utility
852
     */
853
    byCode(
854
        observations: fhirclient.FHIR.Observation | fhirclient.FHIR.Observation[],
855
        property: string
856
    ): fhirclient.ObservationMap
857
    {
858
        return byCode(observations, property);
18✔
859
    }
860

861
    /**
862
     * First groups the observations by code using `byCode`. Then returns a function
863
     * that accepts codes as arguments and will return a flat array of observations
864
     * having that codes. Example:
865
     * ```js
866
     * const filter = client.byCodes(observations, "category");
867
     * filter("laboratory") // => [ observation1, observation2 ]
868
     * filter("vital-signs") // => [ observation3 ]
869
     * filter("laboratory", "vital-signs") // => [ observation1, observation2, observation3 ]
870
     * ```
871
     * @param observations Array of observations
872
     * @param property The name of a CodeableConcept property to group by
873
     * @todo This should be deprecated and moved elsewhere. One should not have
874
     * to obtain an instance of [[Client]] just to use utility functions like this.
875
     * @deprecated
876
     * @category Utility
877
     */
878
    byCodes(
879
        observations: fhirclient.FHIR.Observation | fhirclient.FHIR.Observation[],
880
        property: string
881
    ): (...codes: string[]) => any[]
882
    {
883
        return byCodes(observations, property);
18✔
884
    }
885

886
    /**
887
     * @category Utility
888
     */
889
    units = units;
738✔
890

891
    /**
892
     * Walks through an object (or array) and returns the value found at the
893
     * provided path. This function is very simple so it intentionally does not
894
     * support any argument polymorphism, meaning that the path can only be a
895
     * dot-separated string. If the path is invalid returns undefined.
896
     * @param obj The object (or Array) to walk through
897
     * @param path The path (eg. "a.b.4.c")
898
     * @returns {*} Whatever is found in the path or undefined
899
     * @todo This should be deprecated and moved elsewhere. One should not have
900
     * to obtain an instance of [[Client]] just to use utility functions like this.
901
     * @deprecated
902
     * @category Utility
903
     */
904
    getPath(obj: Record<string, any>, path = ""): any {
3✔
905
        return getPath(obj, path);
12✔
906
    }
907

908
    /**
909
     * Returns a copy of the client state. Accepts a dot-separated path argument
910
     * (same as for `getPath`) to allow for selecting specific properties.
911
     * Examples:
912
     * ```js
913
     * client.getState(); // -> the entire state object
914
     * client.getState("serverUrl"); // -> the URL we are connected to
915
     * client.getState("tokenResponse.patient"); // -> The selected patient ID (if any)
916
     * ```
917
     * @param path The path (eg. "a.b.4.c")
918
     * @returns {*} Whatever is found in the path or undefined
919
     */
920
    getState(path = "") {
6✔
921
        return getPath({ ...this.state }, path);
1,557✔
922
    }
923

924
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc