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

smart-on-fhir / client-js / 18107288963

29 Sep 2025 06:46PM UTC coverage: 94.524% (-0.9%) from 95.451%
18107288963

push

github

vlad-ignatov
Prepare for release

475 of 527 branches covered (90.13%)

Branch coverage included in aggregate %.

4 of 4 new or added lines in 3 files covered. (100.0%)

21 existing lines in 5 files now uncovered.

906 of 934 relevant lines covered (97.0%)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

224
        this.state = _state;
243✔
225
        this.environment = environment;
243✔
226
        this._refreshTask = null;
243✔
227

228
        const client = this;
243✔
229

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

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

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

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

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

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

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

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

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

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

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

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

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

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

460
    /**
461
     * Default request options to be used for every request.
462
     */
463
    async getRequestDefaults(): Promise<Partial<fhirclient.RequestOptions>> {
464
        const authHeader = this.getAuthorizationHeader();
232✔
465
        return {
232✔
466
            headers: {
467
                ...(authHeader ? { authorization: authHeader } : {})
232✔
468
            }
469
        };
470
    }
471

472
    /**
473
     * @param requestOptions Can be a string URL (relative to the serviceUrl),
474
     * or an object which will be passed to fetch()
475
     * @param fhirOptions Additional options to control the behavior
476
     * @param _resolvedRefs DO NOT USE! Used internally.
477
     * @category Request
478
     */
479
    async request<T = any>(
480
        requestOptions: string|URL|fhirclient.RequestOptions,
481
        fhirOptions: fhirclient.FhirOptions = {},
44✔
482
        _resolvedRefs: fhirclient.JsonObject = {}
122✔
483
    ): Promise<T>
484
    {
485
        assert(requestOptions, "request requires an url or request options as argument");
160✔
486

487
        // url -----------------------------------------------------------------
488
        let url: string;
489
        if (typeof requestOptions == "string" || requestOptions instanceof URL) {
159✔
490
            url = String(requestOptions);
83✔
491
            requestOptions = {} as fhirclient.RequestOptions;
83✔
492
        }
493
        else {
494
            url = String(requestOptions.url);
76✔
495
        }
496

497
        url = absolute(url, this.state.serverUrl);
159✔
498

499
        const options = {
159✔
500
            graph: fhirOptions.graph !== false,
501
            flat : !!fhirOptions.flat,
502
            pageLimit: fhirOptions.pageLimit ?? 1,
238✔
503
            resolveReferences: makeArray(fhirOptions.resolveReferences || []) as string[],
242✔
504
            useRefreshToken: fhirOptions.useRefreshToken !== false,
505
            onPage: typeof fhirOptions.onPage == "function" ?
159✔
506
                fhirOptions.onPage as (
507
                    data: fhirclient.JsonObject | fhirclient.JsonObject[],
508
                    references?: fhirclient.JsonObject | undefined) => any :
509
                undefined
510
        };
511

512
        const signal = (requestOptions as RequestInit).signal || undefined;
159✔
513

514
        // Refresh the access token if needed
515
        if (options.useRefreshToken) {
159✔
516
            await this.refreshIfNeeded({ signal })
157✔
517
        }
518

519
        // Add the Authorization header now, after the access token might
520
        // have been updated
521
        const authHeader = this.getAuthorizationHeader();
157✔
522
        if (authHeader) {
157✔
523
            requestOptions.headers = {
11✔
524
                ...requestOptions.headers,
525
                authorization: authHeader
526
            };
527
        }
528

529
        debug("client:request: %s, options: %O, fhirOptions: %O", url, requestOptions, options);
157✔
530

531
        let response: Response | undefined;
532

533
        return super.fhirRequest<fhirclient.FetchResult>(url, requestOptions).then(result => {
157✔
534
            if ((requestOptions as fhirclient.RequestOptions).includeResponse) {
137✔
535
                response = (result as fhirclient.CombinedFetchResult).response;
10✔
536
                return (result as fhirclient.CombinedFetchResult).body;
10✔
537
            }
538
            return result;
127✔
539
        })
540

541
        // Handle 401 ----------------------------------------------------------
542
        .catch(async (error: HttpError) => {
543
            if (error.status == 401) {
20✔
544

545
                // !accessToken -> not authorized -> No session. Need to launch.
546
                if (!this.getState("tokenResponse.access_token")) {
6✔
547
                    error.message += "\nThis app cannot be accessed directly. Please launch it as SMART app!";
2✔
548
                    throw error;
2✔
549
                }
550

551
                // auto-refresh not enabled and Session expired.
552
                // Need to re-launch. Clear state to start over!
553
                if (!options.useRefreshToken) {
4✔
554
                    debug("client:request: Your session has expired and the useRefreshToken option is set to false. Please re-launch the app.");
2✔
555
                    await this._clearState();
2✔
556
                    error.message += "\n" + str.expired;
1✔
557
                    throw error;
1✔
558
                }
559

560
                // In rare cases we may have a valid access token and a refresh
561
                // token and the request might still fail with 401 just because
562
                // the access token has just been revoked.
563

564
                // otherwise -> auto-refresh failed. Session expired.
565
                // Need to re-launch. Clear state to start over!
566
                debug("client:request: Auto-refresh failed! Please re-launch the app.");
2✔
567
                await this._clearState();
2✔
568
                error.message += "\n" + str.expired;
1✔
569
                throw error;
1✔
570
            }
571
            throw error;
14✔
572
        })
573

574
        // Handle 403 ----------------------------------------------------------
575
        .catch((error: HttpError) => {
576
            if (error.status == 403) {
20✔
577
                debug("client:request: Permission denied! Please make sure that you have requested the proper scopes.");
2✔
578
            }
579
            throw error;
20✔
580
        })
581

582
        .then(async (data: any) => {
583

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

606
            return Promise.resolve(data)
123✔
607

608
            // Pagination ------------------------------------------------------
609
            .then(async _data => {
610
                if (_data && _data.resourceType == "Bundle") {
123✔
611
                    const links = (_data.link || []) as fhirclient.FHIR.BundleLink[];
80✔
612

613
                    if (options.flat) {
80✔
614
                        _data = (_data.entry || []).map(
26!
615
                            (entry: fhirclient.FHIR.BundleEntry) => entry.resource
42✔
616
                        );
617
                    }
618

619
                    if (options.onPage) {
80✔
620
                        await options.onPage(_data, { ..._resolvedRefs });
36✔
621
                    }
622

623
                    if (--options.pageLimit) {
76✔
624
                        const next = links.find(l => l.relation == "next");
64✔
625
                        _data = makeArray(_data);
64✔
626
                        if (next && next.url) {
64✔
627
                            const nextPage = await this.request(
38✔
628
                                {
629
                                    url: next.url,
630

631
                                    // Aborting the main request (even after it is complete)
632
                                    // must propagate to any child requests and abort them!
633
                                    // To do so, just pass the same AbortSignal if one is
634
                                    // provided.
635
                                    signal
636
                                },
637
                                options,
638
                                _resolvedRefs
639
                            );
640

641
                            if (options.onPage) {
34✔
642
                                return null;
16✔
643
                            }
644

645
                            if (options.resolveReferences.length) {
18✔
646
                                Object.assign(_resolvedRefs, nextPage.references);
10✔
647
                                return _data.concat(makeArray(nextPage.data || nextPage));
10✔
648
                            }
649
                            return _data.concat(makeArray(nextPage));
8✔
650
                        }
651
                    }
652
                }
653
                return _data;
81✔
654
            })
655

656
            // Finalize --------------------------------------------------------
657
            .then(_data => {
658
                if (options.graph) {
115✔
659
                    _resolvedRefs = {};
95✔
660
                }
661
                else if (!options.onPage && options.resolveReferences.length) {
20✔
662
                    return {
10✔
663
                        data: _data,
664
                        references: _resolvedRefs
665
                    };
666
                }
667
                return _data;
105✔
668
            })
669
            .then(_data => {
670
                if ((requestOptions as fhirclient.FetchOptions).includeResponse) {
115✔
671
                    return {
10✔
672
                        body: _data,
673
                        response
674
                    }
675
                }
676
                return _data;
105✔
677
            });
678
        });
679
    }
680

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

696
        if (accessToken && refreshToken && expiresAt - 10 < Date.now() / 1000) {
157✔
697
            return this.refresh(requestOptions);
7✔
698
        }
699

700
        return Promise.resolve(this.state);
150✔
701
    }
702

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

720
        const refreshToken = this.state?.tokenResponse?.refresh_token;
19✔
721
        assert(refreshToken, "Unable to refresh. No refresh_token found.");
19✔
722

723
        const tokenUri = this.state.tokenUri;
18✔
724
        assert(tokenUri, "Unable to refresh. No tokenUri found.");
18✔
725

726
        const scopes = this.getState("tokenResponse.scope") || "";
15✔
727
        const hasOfflineAccess = scopes.search(/\boffline_access\b/) > -1;
15✔
728
        const hasOnlineAccess = scopes.search(/\bonline_access\b/) > -1;
15✔
729
        assert(hasOfflineAccess || hasOnlineAccess, "Unable to refresh. No offline_access or online_access scope found.");
15✔
730

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

752
            // custom authorization header can be passed on manual calls
753
            if (!("authorization" in refreshRequestOptions.headers)) {
12✔
754
                const { clientSecret, clientId } = this.state;
11✔
755
                if (clientSecret) {
11✔
756
                    // @ts-ignore
757
                    refreshRequestOptions.headers.authorization = "Basic " + this.environment.btoa(
1✔
758
                        clientId + ":" + clientSecret
759
                    );
760
                }
761
            }
762

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

789
        return this._refreshTask;
13✔
790
    }
791

792
    // utils -------------------------------------------------------------------
793

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

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

843
    /**
844
     * @category Utility
845
     */
846
    units = units;
243✔
847

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

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

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