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

smart-on-fhir / client-js / 17987503568

24 Sep 2025 07:33PM UTC coverage: 95.42% (+0.8%) from 94.668%
17987503568

push

github

vlad-ignatov
Latest changes

468 of 511 branches covered (91.59%)

Branch coverage included in aggregate %.

907 of 930 relevant lines covered (97.53%)

44.24 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 BrowserAdapter from "./adapters/BrowserAdapter";
21
import { fhirclient } from "./types";
22
import FhirClient from "./FhirClient";
1✔
23

24

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

231
        const client = this;
243✔
232

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

523
        let response: Response | undefined;
524

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

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

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

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

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

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

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

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

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

598
            return Promise.resolve(data)
123✔
599

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

782
        return this._refreshTask;
13✔
783
    }
784

785
    // utils -------------------------------------------------------------------
786

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

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

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

841
    /**
842
     * Walks through an object (or array) and returns the value found at the
843
     * provided path. This function is very simple so it intentionally does not
844
     * support any argument polymorphism, meaning that the path can only be a
845
     * dot-separated string. If the path is invalid returns undefined.
846
     * @param obj The object (or Array) to walk through
847
     * @param path The path (eg. "a.b.4.c")
848
     * @returns {*} Whatever is found in the path or undefined
849
     * @remarks This should be deprecated and moved elsewhere. One should not have
850
     * to obtain an instance of [[Client]] just to use utility functions like this.
851
     * @deprecated
852
     * @category Utility
853
     */
854
    getPath(obj: Record<string, any>, path = ""): any {
1✔
855
        return getPath(obj, path);
4✔
856
    }
857

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

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