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

smart-on-fhir / client-js / 18012506802

25 Sep 2025 03:26PM UTC coverage: 95.417% (-0.003%) from 95.42%
18012506802

push

github

vlad-ignatov
Latest build

468 of 511 branches covered (91.59%)

Branch coverage included in aggregate %.

906 of 929 relevant lines covered (97.52%)

44.29 hits per line

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

98.03
/src/Client.ts
1
import {
1✔
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";
1✔
18
import { SMART_KEY, patientCompartment } from "./settings";
1✔
19
import HttpError from "./HttpError";
20
import { fhirclient } from "./types";
21
import FhirClient from "./FhirClient";
1✔
22

23

24
const debug = _debug.extend("client");
1✔
25

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

40
    async function contextualURL(_url: URL) {
41
        const resourceType = _url.pathname.split("/").pop();
22✔
42
        assert(resourceType, `Invalid url "${_url}"`);
22✔
43
        assert(patientCompartment.indexOf(resourceType) > -1, `Cannot filter "${resourceType}" resources by patient`);
20✔
44
        const conformance = await fetchConformanceStatement(client.state.serverUrl);
18✔
45
        const searchParam = getPatientParam(conformance, resourceType);
18✔
46
        _url.searchParams.set(searchParam, client.patient.id as string);
12✔
47
        return _url.href;
12✔
48
    }
49

50
    if (typeof requestOptions == "string" || requestOptions instanceof URL) {
22✔
51
        return { url: await contextualURL(new URL(requestOptions + "", base)) };
16✔
52
    }
53

54
    requestOptions.url = await contextualURL(new URL(requestOptions.url + "", base));
6✔
55
    return requestOptions;
6✔
56
}
57

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

80
    /**
81
     * The adapter to use to connect to the current environment. Currently we have:
82
     * - BrowserAdapter - for browsers
83
     * - NodeAdapter - for Express or vanilla NodeJS servers
84
     * - HapiAdapter - for HAPI NodeJS servers
85
     */
86
    readonly environment: fhirclient.Adapter;
87

88
    /**
89
     * A SMART app is typically associated with a patient. This is a namespace
90
     * for the patient-related functionality of the client.
91
     */
92
    readonly patient: {
93

94
        /**
95
         * The ID of the current patient or `null` if there is no current patient
96
         */
97
        id: string | null
98

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

129
        /**
130
         * This is the FhirJS Patient API. It will ONLY exist if the `Client`
131
         * instance is "connected" to FhirJS.
132
         */
133
        api?: Record<string, any>
134
    };
135

136
    /**
137
     * The client may be associated with a specific encounter, if the scopes
138
     * permit that and if the back-end server supports that. This is a namespace
139
     * for encounter-related functionality.
140
     */
141
    readonly encounter: {
142

143
        /**
144
         * The ID of the current encounter or `null` if there is no current
145
         * encounter
146
         */
147
        id: string | null
148

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

158
    /**
159
     * The client may be associated with a specific user, if the scopes
160
     * permit that. This is a namespace for user-related functionality.
161
     */
162
    readonly user: {
163

164
        /**
165
         * The ID of the current user or `null` if there is no current user
166
         */
167
        id: string | null
168

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

181
        /**
182
         * Returns the profile of the logged_in user (if any), or null if the
183
         * user is not available. This is a string having the shape
184
         * `{user type}/{user id}`. For example `Practitioner/abc` or
185
         * `Patient/xyz`.
186
         * @see client.getFhirUser()
187
         */
188
        fhirUser: string | null
189

190
        /**
191
         * Returns the type of the logged-in user or null. The result can be
192
         * `Practitioner`, `Patient` or `RelatedPerson`.
193
         * @see client.getUserType()
194
         */
195
        resourceType: string | null
196
    };
197

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

205
    /**
206
     * Refers to the refresh task while it is being performed.
207
     * @see [[refresh]]
208
     */
209
    private _refreshTask: Promise<any> | null;
210

211
    /**
212
     * Validates the parameters and creates an instance.
213
     */
214
    constructor(environment: fhirclient.Adapter, state: fhirclient.ClientState | string)
215
    {
216
        const _state = typeof state == "string" ? { serverUrl: state } : state;
247✔
217
        
218
        // Valid serverUrl is required!
219
        assert(
247✔
220
            _state.serverUrl && _state.serverUrl.match(/https?:\/\/.+/),
491✔
221
            "A \"serverUrl\" option is required and must begin with \"http(s)\""
222
        );
223
        
224
        super(_state.serverUrl)
243✔
225

226
        this.state = _state;
243✔
227
        this.environment = environment;
243✔
228
        this._refreshTask = null;
243✔
229

230
        const client = this;
243✔
231

232
        // patient api ---------------------------------------------------------
233
        this.patient = {
243✔
234
            get id() { return client.getPatientId(); },
44✔
235
            read: (requestOptions) => {
236
                const id = this.patient.id;
4✔
237
                return id ?
4✔
238
                    this.request({ ...requestOptions, url: `Patient/${id}` }) :
239
                    Promise.reject(new Error("Patient is not available"));
240
            },
241
            request: (requestOptions, fhirOptions = {}) => {
24✔
242
                if (this.patient.id) {
24✔
243
                    return (async () => {
22✔
244
                        const options = await contextualize(requestOptions, this);
22✔
245
                        return this.request(options, fhirOptions);
12✔
246
                    })();
247
                } else {
248
                    return Promise.reject(new Error("Patient is not available"));
2✔
249
                }
250
            }
251
        };
252

253
        // encounter api -------------------------------------------------------
254
        this.encounter = {
243✔
255
            get id() { return client.getEncounterId(); },
12✔
256
            read: requestOptions => {
257
                const id = this.encounter.id;
8✔
258
                return id ?
8✔
259
                    this.request({ ...requestOptions, url: `Encounter/${id}` }) :
260
                    Promise.reject(new Error("Encounter is not available"));
261
            }
262
        };
263

264
        // user api ------------------------------------------------------------
265
        this.user = {
243✔
266
            get fhirUser() { return client.getFhirUser(); },
6✔
267
            get id() { return client.getUserId(); },
8✔
268
            get resourceType() { return client.getUserType(); },
4✔
269
            read: requestOptions => {
270
                const fhirUser = this.user.fhirUser;
6✔
271
                return fhirUser ?
6✔
272
                    this.request({ ...requestOptions, url: fhirUser }) :
273
                    Promise.reject(new Error("User is not available"));
274
            }
275
        };
276
    }
277

278
    /**
279
     * Returns the ID of the selected patient or null. You should have requested
280
     * "launch/patient" scope. Otherwise this will return null.
281
     */
282
    getPatientId(): string | null
283
    {
284
        const tokenResponse = this.state.tokenResponse;
60✔
285
        if (tokenResponse) {
60✔
286
            // We have been authorized against this server but we don't know
287
            // the patient. This should be a scope issue.
288
            if (!tokenResponse.patient) {
56✔
289
                if (!(this.state.scope || "").match(/\blaunch(\/patient)?\b/)) {
8✔
290
                    debug(str.noScopeForId, "patient", "patient");
6✔
291
                }
292
                else {
293
                    // The server should have returned the patient!
294
                    debug("The ID of the selected patient is not available. Please check if your server supports that.");
2✔
295
                }
296
                return null;
8✔
297
            }
298
            return tokenResponse.patient;
48✔
299
        }
300

301
        if (this.state.authorizeUri) {
4✔
302
            debug(str.noIfNoAuth, "the ID of the selected patient");
2✔
303
        }
304
        else {
305
            debug(str.noFreeContext, "selected patient");
2✔
306
        }
307
        return null;
4✔
308
    }
309

310
    /**
311
     * Returns the ID of the selected encounter or null. You should have
312
     * requested "launch/encounter" scope. Otherwise this will return null.
313
     * Note that not all servers support the "launch/encounter" scope so this
314
     * will be null if they don't.
315
     */
316
    getEncounterId(): string | null
317
    {
318
        const tokenResponse = this.state.tokenResponse;
28✔
319
        if (tokenResponse) {
28✔
320
            // We have been authorized against this server but we don't know
321
            // the encounter. This should be a scope issue.
322
            if (!tokenResponse.encounter) {
24✔
323
                if (!(this.state.scope || "").match(/\blaunch(\/encounter)?\b/)) {
6✔
324
                    debug(str.noScopeForId, "encounter", "encounter");
4✔
325
                }
326
                else {
327
                    // The server should have returned the encounter!
328
                    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.");
2✔
329
                }
330
                return null;
6✔
331
            }
332
            return tokenResponse.encounter;
18✔
333
        }
334

335
        if (this.state.authorizeUri) {
4✔
336
            debug(str.noIfNoAuth, "the ID of the selected encounter");
2✔
337
        }
338
        else {
339
            debug(str.noFreeContext, "selected encounter");
2✔
340
        }
341
        return null;
4✔
342
    }
343

344
    /**
345
     * Returns the (decoded) id_token if any. You need to request "openid" and
346
     * "profile" scopes if you need to receive an id_token (if you need to know
347
     * who the logged-in user is).
348
     */
349
    getIdToken(): fhirclient.IDToken | null
350
    {
351
        const tokenResponse = this.state.tokenResponse;
60✔
352
        if (tokenResponse) {
60✔
353
            const idToken = tokenResponse.id_token;
56✔
354
            const scope = this.state.scope || "";
56✔
355

356
            // We have been authorized against this server but we don't have
357
            // the id_token. This should be a scope issue.
358
            if (!idToken) {
56✔
359
                const hasOpenid   = scope.match(/\bopenid\b/);
14✔
360
                const hasProfile  = scope.match(/\bprofile\b/);
14✔
361
                const hasFhirUser = scope.match(/\bfhirUser\b/);
14✔
362
                if (!hasOpenid || !(hasFhirUser || hasProfile)) {
14!
363
                    debug(
12✔
364
                        "You are trying to get the id_token but you are not " +
365
                        "using the right scopes. Please add 'openid' and " +
366
                        "'fhirUser' or 'profile' to the scopes you are " +
367
                        "requesting."
368
                    );
369
                }
370
                else {
371
                    // The server should have returned the id_token!
372
                    debug("The id_token is not available. Please check if your server supports that.");
2✔
373
                }
374
                return null;
14✔
375
            }
376
            return jwtDecode(idToken, this.environment) as fhirclient.IDToken;
42✔
377
        }
378
        if (this.state.authorizeUri) {
4✔
379
            debug(str.noIfNoAuth, "the id_token");
2✔
380
        }
381
        else {
382
            debug(str.noFreeContext, "id_token");
2✔
383
        }
384
        return null;
4✔
385
    }
386

387
    /**
388
     * Returns the profile of the logged_in user (if any). This is a string
389
     * having the following shape `"{user type}/{user id}"`. For example:
390
     * `"Practitioner/abc"` or `"Patient/xyz"`.
391
     */
392
    getFhirUser(): string | null
393
    {
394
        const idToken = this.getIdToken();
52✔
395
        if (idToken) {
52✔
396
            // Epic may return a full url
397
            // @see https://github.com/smart-on-fhir/client-js/issues/105
398
            if (idToken.fhirUser) {
42✔
399
                return idToken.fhirUser.split("/").slice(-2).join("/");
40✔
400
            }
401
            return idToken.profile
2✔
402
        }
403
        return null;
10✔
404
    }
405

406
    /**
407
     * Returns the user ID or null.
408
     */
409
    getUserId(): string | null
410
    {
411
        const profile = this.getFhirUser();
20✔
412
        if (profile) {
20✔
413
            return profile.split("/")[1];
16✔
414
        }
415
        return null;
4✔
416
    }
417

418
    /**
419
     * Returns the type of the logged-in user or null. The result can be
420
     * "Practitioner", "Patient" or "RelatedPerson".
421
     */
422
    getUserType(): string | null
423
    {
424
        const profile = this.getFhirUser();
16✔
425
        if (profile) {
16✔
426
            return profile.split("/")[0];
14✔
427
        }
428
        return null;
2✔
429
    }
430

431
    /**
432
     * Builds and returns the value of the `Authorization` header that can be
433
     * sent to the FHIR server
434
     */
435
    getAuthorizationHeader(): string | null
436
    {
437
        const accessToken = this.getState("tokenResponse.access_token");
165✔
438
        if (accessToken) {
165✔
439
            return "Bearer " + accessToken;
13✔
440
        }
441
        const { username, password } = this.state;
152✔
442
        if (username && password) {
152✔
443
            return "Basic " + this.environment.btoa(username + ":" + password);
2✔
444
        }
445
        return null;
150✔
446
    }
447

448
    /**
449
     * Used internally to clear the state of the instance and the state in the
450
     * associated storage.
451
     */
452
    private async _clearState() {
453
        const storage = this.environment.getStorage();
6✔
454
        const key = await storage.get(SMART_KEY);
6✔
455
        if (key) {
4✔
456
            await storage.unset(key);
2✔
457
        }
458
        await storage.unset(SMART_KEY);
4✔
459
        this.state.tokenResponse = {};
4✔
460
    }
461

462
    /**
463
     * @param requestOptions Can be a string URL (relative to the serviceUrl),
464
     * or an object which will be passed to fetch()
465
     * @param fhirOptions Additional options to control the behavior
466
     * @param _resolvedRefs DO NOT USE! Used internally.
467
     * @category Request
468
     */
469
    async request<T = any>(
470
        requestOptions: string|URL|fhirclient.RequestOptions,
471
        fhirOptions: fhirclient.FhirOptions = {},
44✔
472
        _resolvedRefs: fhirclient.JsonObject = {}
122✔
473
    ): Promise<T>
474
    {
475
        const debugRequest = _debug.extend("client:request");
160✔
476
        assert(requestOptions, "request requires an url or request options as argument");
160✔
477

478
        // url -----------------------------------------------------------------
479
        let url: string;
480
        if (typeof requestOptions == "string" || requestOptions instanceof URL) {
159✔
481
            url = String(requestOptions);
83✔
482
            requestOptions = {} as fhirclient.RequestOptions;
83✔
483
        }
484
        else {
485
            url = String(requestOptions.url);
76✔
486
        }
487

488
        url = absolute(url, this.state.serverUrl);
159✔
489

490
        const options = {
159✔
491
            graph: fhirOptions.graph !== false,
492
            flat : !!fhirOptions.flat,
493
            pageLimit: fhirOptions.pageLimit ?? 1,
238✔
494
            resolveReferences: makeArray(fhirOptions.resolveReferences || []) as string[],
242✔
495
            useRefreshToken: fhirOptions.useRefreshToken !== false,
496
            onPage: typeof fhirOptions.onPage == "function" ?
159✔
497
                fhirOptions.onPage as (
498
                    data: fhirclient.JsonObject | fhirclient.JsonObject[],
499
                    references?: fhirclient.JsonObject | undefined) => any :
500
                undefined
501
        };
502

503
        const signal = (requestOptions as RequestInit).signal || undefined;
159✔
504

505
        // Refresh the access token if needed
506
        if (options.useRefreshToken) {
159✔
507
            await this.refreshIfNeeded({ signal })
157✔
508
        }
509

510
        // Add the Authorization header now, after the access token might
511
        // have been updated
512
        const authHeader = this.getAuthorizationHeader();
157✔
513
        if (authHeader) {
157✔
514
            requestOptions.headers = {
11✔
515
                ...requestOptions.headers,
516
                authorization: authHeader
517
            };
518
        }
519

520
        debugRequest("%s, options: %O, fhirOptions: %O", url, requestOptions, options);
157✔
521

522
        let response: Response | undefined;
523

524
        return super.fhirRequest<fhirclient.FetchResult>(url, requestOptions).then(result => {
157✔
525
            if ((requestOptions as fhirclient.RequestOptions).includeResponse) {
137✔
526
                response = (result as fhirclient.CombinedFetchResult).response;
10✔
527
                return (result as fhirclient.CombinedFetchResult).body;
10✔
528
            }
529
            return result;
127✔
530
        })
531

532
        // Handle 401 ----------------------------------------------------------
533
        .catch(async (error: HttpError) => {
534
            if (error.status == 401) {
20✔
535

536
                // !accessToken -> not authorized -> No session. Need to launch.
537
                if (!this.getState("tokenResponse.access_token")) {
6✔
538
                    error.message += "\nThis app cannot be accessed directly. Please launch it as SMART app!";
2✔
539
                    throw error;
2✔
540
                }
541

542
                // auto-refresh not enabled and Session expired.
543
                // Need to re-launch. Clear state to start over!
544
                if (!options.useRefreshToken) {
4✔
545
                    debugRequest("Your session has expired and the useRefreshToken option is set to false. Please re-launch the app.");
2✔
546
                    await this._clearState();
2✔
547
                    error.message += "\n" + str.expired;
1✔
548
                    throw error;
1✔
549
                }
550

551
                // In rare cases we may have a valid access token and a refresh
552
                // token and the request might still fail with 401 just because
553
                // the access token has just been revoked.
554

555
                // otherwise -> auto-refresh failed. Session expired.
556
                // Need to re-launch. Clear state to start over!
557
                debugRequest("Auto-refresh failed! Please re-launch the app.");
2✔
558
                await this._clearState();
2✔
559
                error.message += "\n" + str.expired;
1✔
560
                throw error;
1✔
561
            }
562
            throw error;
14✔
563
        })
564

565
        // Handle 403 ----------------------------------------------------------
566
        .catch((error: HttpError) => {
567
            if (error.status == 403) {
20✔
568
                debugRequest("Permission denied! Please make sure that you have requested the proper scopes.");
2✔
569
            }
570
            throw error;
20✔
571
        })
572

573
        .then(async (data: any) => {
574

575
            // At this point we don't know what `data` actually is!
576
            // We might get an empty or falsy result. If so return it as is
577
            // Also handle raw responses
578
            if (!data || typeof data == "string" || data instanceof Response) {
137✔
579
                if ((requestOptions as fhirclient.FetchOptions).includeResponse) {
12!
580
                    return {
×
581
                        body: data,
582
                        response
583
                    }
584
                }
585
                return data;
12✔
586
            }
587
            
588
            // Resolve References ----------------------------------------------
589
            await this.fetchReferences(
125✔
590
                data as any,
591
                options.resolveReferences,
592
                options.graph,
593
                _resolvedRefs,
594
                requestOptions as fhirclient.RequestOptions
595
            );
596

597
            return Promise.resolve(data)
123✔
598

599
            // Pagination ------------------------------------------------------
600
            .then(async _data => {
601
                if (_data && _data.resourceType == "Bundle") {
123✔
602
                    const links = (_data.link || []) as fhirclient.FHIR.BundleLink[];
80✔
603

604
                    if (options.flat) {
80✔
605
                        _data = (_data.entry || []).map(
26!
606
                            (entry: fhirclient.FHIR.BundleEntry) => entry.resource
42✔
607
                        );
608
                    }
609

610
                    if (options.onPage) {
80✔
611
                        await options.onPage(_data, { ..._resolvedRefs });
36✔
612
                    }
613

614
                    if (--options.pageLimit) {
76✔
615
                        const next = links.find(l => l.relation == "next");
64✔
616
                        _data = makeArray(_data);
64✔
617
                        if (next && next.url) {
64✔
618
                            const nextPage = await this.request(
38✔
619
                                {
620
                                    url: next.url,
621

622
                                    // Aborting the main request (even after it is complete)
623
                                    // must propagate to any child requests and abort them!
624
                                    // To do so, just pass the same AbortSignal if one is
625
                                    // provided.
626
                                    signal
627
                                },
628
                                options,
629
                                _resolvedRefs
630
                            );
631

632
                            if (options.onPage) {
34✔
633
                                return null;
16✔
634
                            }
635

636
                            if (options.resolveReferences.length) {
18✔
637
                                Object.assign(_resolvedRefs, nextPage.references);
10✔
638
                                return _data.concat(makeArray(nextPage.data || nextPage));
10✔
639
                            }
640
                            return _data.concat(makeArray(nextPage));
8✔
641
                        }
642
                    }
643
                }
644
                return _data;
81✔
645
            })
646

647
            // Finalize --------------------------------------------------------
648
            .then(_data => {
649
                if (options.graph) {
115✔
650
                    _resolvedRefs = {};
95✔
651
                }
652
                else if (!options.onPage && options.resolveReferences.length) {
20✔
653
                    return {
10✔
654
                        data: _data,
655
                        references: _resolvedRefs
656
                    };
657
                }
658
                return _data;
105✔
659
            })
660
            .then(_data => {
661
                if ((requestOptions as fhirclient.FetchOptions).includeResponse) {
115✔
662
                    return {
10✔
663
                        body: _data,
664
                        response
665
                    }
666
                }
667
                return _data;
105✔
668
            });
669
        });
670
    }
671

672
    /**
673
     * Checks if access token and refresh token are present. If they are, and if
674
     * the access token is expired or is about to expire in the next 10 seconds,
675
     * calls `this.refresh()` to obtain new access token.
676
     * @param requestOptions Any options to pass to the fetch call. Most of them
677
     * will be overridden, bit it might still be useful for passing additional
678
     * request options or an abort signal.
679
     * @category Request
680
     */
681
    refreshIfNeeded(requestOptions: RequestInit = {}): Promise<fhirclient.ClientState>
×
682
    {
683
        const accessToken  = this.getState("tokenResponse.access_token");
157✔
684
        const refreshToken = this.getState("tokenResponse.refresh_token");
157✔
685
        const expiresAt    = this.state.expiresAt || 0;
157✔
686

687
        if (accessToken && refreshToken && expiresAt - 10 < Date.now() / 1000) {
157✔
688
            return this.refresh(requestOptions);
7✔
689
        }
690

691
        return Promise.resolve(this.state);
150✔
692
    }
693

694
    /**
695
     * Use the refresh token to obtain new access token. If the refresh token is
696
     * expired (or this fails for any other reason) it will be deleted from the
697
     * state, so that we don't enter into loops trying to re-authorize.
698
     *
699
     * This method is typically called internally from [[request]] if
700
     * certain request fails with 401.
701
     *
702
     * @param requestOptions Any options to pass to the fetch call. Most of them
703
     * will be overridden, bit it might still be useful for passing additional
704
     * request options or an abort signal.
705
     * @category Request
706
     */
707
    refresh(requestOptions: RequestInit = {}): Promise<fhirclient.ClientState>
10✔
708
    {
709
        const debugRefresh = _debug.extend("client:refresh");
19✔
710
        debugRefresh("Attempting to refresh with refresh_token...");
19✔
711

712
        const refreshToken = this.state?.tokenResponse?.refresh_token;
19✔
713
        assert(refreshToken, "Unable to refresh. No refresh_token found.");
19✔
714

715
        const tokenUri = this.state.tokenUri;
18✔
716
        assert(tokenUri, "Unable to refresh. No tokenUri found.");
18✔
717

718
        const scopes = this.getState("tokenResponse.scope") || "";
15✔
719
        const hasOfflineAccess = scopes.search(/\boffline_access\b/) > -1;
15✔
720
        const hasOnlineAccess = scopes.search(/\bonline_access\b/) > -1;
15✔
721
        assert(hasOfflineAccess || hasOnlineAccess, "Unable to refresh. No offline_access or online_access scope found.");
15✔
722

723
        // This method is typically called internally from `request` if certain
724
        // request fails with 401. However, clients will often run multiple
725
        // requests in parallel which may result in multiple refresh calls.
726
        // To avoid that, we keep a reference to the current refresh task (if any).
727
        if (!this._refreshTask) {
13✔
728
            let body = `grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`;
12✔
729
            if (this.environment.options.refreshTokenWithClientId) {
12!
730
                body += `&client_id=${this.state.clientId}`;
×
731
            }
732
            const refreshRequestOptions = {
12✔
733
                credentials: this.environment.options.refreshTokenWithCredentials || "same-origin",
14✔
734
                ...requestOptions,
735
                method : "POST",
736
                mode   : "cors" as RequestMode,
737
                headers: {
738
                    ...(requestOptions.headers || {}),
22✔
739
                    "content-type": "application/x-www-form-urlencoded"
740
                },
741
                body: body
742
            };
743

744
            // custom authorization header can be passed on manual calls
745
            if (!("authorization" in refreshRequestOptions.headers)) {
12✔
746
                const { clientSecret, clientId } = this.state;
11✔
747
                if (clientSecret) {
11✔
748
                    // @ts-ignore
749
                    refreshRequestOptions.headers.authorization = "Basic " + this.environment.btoa(
1✔
750
                        clientId + ":" + clientSecret
751
                    );
752
                }
753
            }
754

755
            this._refreshTask = request<fhirclient.TokenResponse>(tokenUri, refreshRequestOptions)
12✔
756
            .then(data => {
757
                assert(data.access_token, "No access token received");
12✔
758
                debugRefresh("Received new access token response %O", data);
10✔
759
                this.state.tokenResponse = { ...this.state.tokenResponse, ...data };
10✔
760
                this.state.expiresAt = getAccessTokenExpiration(data, this.environment);
10✔
761
                return this.state;
10✔
762
            })
763
            .catch((error: Error) => {
764
                if (this.state?.tokenResponse?.refresh_token) {
2✔
765
                    debugRefresh("Deleting the expired or invalid refresh token.");
2✔
766
                    delete this.state.tokenResponse.refresh_token;
2✔
767
                }
768
                throw error;
2✔
769
            })
770
            .finally(() => {
771
                this._refreshTask = null;
12✔
772
                const key = this.state.key;
12✔
773
                if (key) {
12✔
774
                    this.environment.getStorage().set(key, this.state);
6✔
775
                } else {
776
                    debugRefresh("No 'key' found in Clint.state. Cannot persist the instance.");
6✔
777
                }
778
            });
779
        }
780

781
        return this._refreshTask;
13✔
782
    }
783

784
    // utils -------------------------------------------------------------------
785

786
    /**
787
     * Groups the observations by code. Returns a map that will look like:
788
     * ```js
789
     * const map = client.byCodes(observations, "code");
790
     * // map = {
791
     * //     "55284-4": [ observation1, observation2 ],
792
     * //     "6082-2": [ observation3 ]
793
     * // }
794
     * ```
795
     * @param observations Array of observations
796
     * @param property The name of a CodeableConcept property to group by
797
     * @remarks This should be deprecated and moved elsewhere. One should not have
798
     * to obtain an instance of [[Client]] just to use utility functions like this.
799
     * @deprecated
800
     * @category Utility
801
     */
802
    byCode(
803
        observations: fhirclient.FHIR.Observation | fhirclient.FHIR.Observation[],
804
        property: string
805
    ): fhirclient.ObservationMap
806
    {
807
        return byCode(observations, property);
6✔
808
    }
809

810
    /**
811
     * First groups the observations by code using `byCode`. Then returns a function
812
     * that accepts codes as arguments and will return a flat array of observations
813
     * having that codes. Example:
814
     * ```js
815
     * const filter = client.byCodes(observations, "category");
816
     * filter("laboratory") // => [ observation1, observation2 ]
817
     * filter("vital-signs") // => [ observation3 ]
818
     * filter("laboratory", "vital-signs") // => [ observation1, observation2, observation3 ]
819
     * ```
820
     * @param observations Array of observations
821
     * @param property The name of a CodeableConcept property to group by
822
     * @remarks This should be deprecated and moved elsewhere. One should not have
823
     * to obtain an instance of [[Client]] just to use utility functions like this.
824
     * @deprecated
825
     * @category Utility
826
     */
827
    byCodes(
828
        observations: fhirclient.FHIR.Observation | fhirclient.FHIR.Observation[],
829
        property: string
830
    ): (...codes: string[]) => any[]
831
    {
832
        return byCodes(observations, property);
6✔
833
    }
834

835
    /**
836
     * @category Utility
837
     */
838
    units = units;
243✔
839

840
    /**
841
     * Walks through an object (or array) and returns the value found at the
842
     * provided path. This function is very simple so it intentionally does not
843
     * support any argument polymorphism, meaning that the path can only be a
844
     * dot-separated string. If the path is invalid returns undefined.
845
     * @param obj The object (or Array) to walk through
846
     * @param path The path (eg. "a.b.4.c")
847
     * @returns {*} Whatever is found in the path or undefined
848
     * @remarks 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
    getPath(obj: Record<string, any>, path = ""): any {
1✔
854
        return getPath(obj, path);
4✔
855
    }
856

857
    /**
858
     * Returns a copy of the client state. Accepts a dot-separated path argument
859
     * (same as for `getPath`) to allow for selecting specific properties.
860
     * Examples:
861
     * ```js
862
     * client.getState(); // -> the entire state object
863
     * client.getState("serverUrl"); // -> the URL we are connected to
864
     * client.getState("tokenResponse.patient"); // -> The selected patient ID (if any)
865
     * ```
866
     * @param path The path (eg. "a.b.4.c")
867
     * @returns {*} Whatever is found in the path or undefined
868
     */
869
    getState(path = "") {
2✔
870
        return getPath({ ...this.state }, path);
505✔
871
    }
872

873
}
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