pathology/viewer/src/services/fhir-store.service.ts (248 lines of code) (raw):
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BehaviorSubject, Observable, ReplaySubject } from 'rxjs';
import { DiagnosticReport, FhirSearchResults } from '../interfaces/fhir_store';
import { Injectable, OnDestroy, isDevMode } from '@angular/core';
import { catchError, finalize, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { HttpClient } from '@angular/common/http';
import { LogService } from './log.service';
import { SearchDiagnosticReportSort } from '../interfaces/fhir_store';
import { WindowService } from './window.service';
import { environment } from '../environments/environment';
import { textToSnippet } from '../utils/common';
declare interface SearchParams {
_text: string | undefined;
_elements: string;
_count: number | undefined;
_sort: SearchDiagnosticReportSort | undefined;
}
enum DiagnosticReportLinkRelation {
SEARCH = 'search',
NEXT = 'next',
}
declare interface SearchDiagnosticReportParams {
nextPageToken?: string; // Token to fetch next page
pageSize?: number; // Max items in a single page
searchKeywords: Set<string>; // Unique words in search
searchText?: string; // Text to search
sort?: SearchDiagnosticReportSort; // Available sorting for search
sortByParams?: string; // Modified sort by search link
}
const DEFAULT_PAGE_SIZE = 20;
const DEFAULT_SNIPPET_WORDS_PER_LINE = 95;
const DEFAULT_SNIPPET_LINES = 4;
const DEFAULT_SNIPPET_LENGTH =
DEFAULT_SNIPPET_WORDS_PER_LINE * DEFAULT_SNIPPET_LINES;
const SORT_DELIMITER = '&_sort=';
/** Fhir search results */
@Injectable({
providedIn: 'root'
})
export class FhirStoreService
implements OnDestroy {
diagnosticReports$ = new BehaviorSubject<DiagnosticReport[]>([]);
loadingDiagnosticReports$ = new BehaviorSubject<boolean>(false);
loadingMoreDiagnosticReports$ = new BehaviorSubject<boolean>(false);
nextDiagnosticReportLink$ = new BehaviorSubject<string>('');
searchResults$ = new BehaviorSubject<FhirSearchResults | undefined>(undefined);
totalDiagnosticReports$ = new BehaviorSubject<number>(0);
private readonly destroyed$ = new ReplaySubject<boolean>(1);
constructor(
private readonly authService: AuthService,
private readonly http: HttpClient,
private readonly logService: LogService,
private readonly windowService: WindowService,
) { }
ngOnDestroy() {
this.destroyed$.next(true);
this.destroyed$.complete();
}
searchDiagnosticReport(params: SearchDiagnosticReportParams):
Observable<FhirSearchResults> {
if (!params.nextPageToken && !params.searchText && !params.sortByParams) {
const errorMessage =
`Invalid call to searchDiagnosticReport: ${JSON.stringify(params)}`;
this.logService.error(
{ name: `searchDiagnosticReport`, message: errorMessage });
throw new Error(errorMessage);
}
return this.authService.getOAuthToken().pipe(
takeUntil(this.destroyed$), switchMap((accessToken) => {
const headers: {
authorization: string,
prefer?: string,
} = {
'authorization': 'Bearer ' + accessToken,
prefer: 'handling=strict',
};
if (!isDevMode()) {
delete headers.prefer;
}
const searchUrl = this.computeSearchDiagnosticReportURL(params);
if (!this.loadingMoreDiagnosticReports$.getValue()) {
this.loadingDiagnosticReports$.next(true);
}
return this.http
.get<FhirSearchResults>(
searchUrl, { headers, withCredentials: false })
.pipe(
takeUntil(this.destroyed$),
map((searchResults: FhirSearchResults) => {
if (searchResults.entry) {
const searchKeywords = params.searchKeywords ?? new Set();
searchResults.entry = this.sanitizeDiagnosticReports(
searchResults.entry, searchKeywords);
} else {
searchResults.entry = [];
}
return searchResults;
}),
tap((modifiedSearchResults: FhirSearchResults) => {
this.updateDiagnosticReportSearchResults(
modifiedSearchResults, params.nextPageToken);
}),
catchError((error) => {
this.resetSearchResults();
error = JSON.stringify(error);
this.logService.error(
{ name: `httpRequest: "${searchUrl}"`, message: error });
let errorMessage =
'Error while fetching search results from FHIR store.';
if (params.nextPageToken) {
errorMessage =
'Error while fetching next page search results from FHIR store.';
}
throw new Error(errorMessage);
}),
finalize(() => {
this.loadingDiagnosticReports$.next(false);
}),
);
}));
}
diagnosticReportNextPage(nextPageLink: string, searchKeywords: Set<string>):
Observable<FhirSearchResults> {
if (!nextPageLink) {
const errorMessage = 'Invalid next page token';
this.logService.error({
name: `nextDiagnosticReportLink: "${nextPageLink}"`,
message: errorMessage
});
throw new Error(errorMessage);
}
const nextPageToken = this.parseUrlParams(nextPageLink);
this.loadingMoreDiagnosticReports$.next(true);
return this.searchDiagnosticReport({ nextPageToken, searchKeywords })
.pipe(
finalize(() => {
this.loadingMoreDiagnosticReports$.next(false);
}),
);
}
diagnosticReportBySort(
diagnosticReportsSortBy: SearchDiagnosticReportSort,
searchKeywords: Set<string>,
) {
const searchLink: string =
(this.searchResults$?.getValue() ?? {} as FhirSearchResults)
.link
.find(({ relation }) => {
return relation === DiagnosticReportLinkRelation.SEARCH;
})
?.url ??
'';
if (!searchLink) {
const errorMessage =
'Invalid call to diagnosticReportBySort - cannot find previous search link.';
this.logService.error(
{ name: `searchDiagnosticReport`, message: errorMessage });
throw new Error(errorMessage);
}
// Replace sort param in previous search url
const sortParams = searchLink.slice(
searchLink.indexOf(SORT_DELIMITER) + SORT_DELIMITER.length);
const prevSort = sortParams.slice(0, sortParams.indexOf('&'));
const indexOfPrevSort = searchLink.indexOf(prevSort);
const beforePrevSort = searchLink.slice(0, indexOfPrevSort);
const afterPrevSort = searchLink.slice(indexOfPrevSort + prevSort.length);
const sortByParamsUrl =
`${beforePrevSort}${diagnosticReportsSortBy}${afterPrevSort}`;
const parsedSortByParams = this.parseUrlParams(sortByParamsUrl);
return this.searchDiagnosticReport(
{ sortByParams: parsedSortByParams, searchKeywords });
}
private computeSearchDiagnosticReportURL(
{ searchText, nextPageToken, pageSize, sort, sortByParams }:
SearchDiagnosticReportParams): string {
if (searchText) {
pageSize = pageSize ?? DEFAULT_PAGE_SIZE;
sort = sort ?? SearchDiagnosticReportSort.LAST_MODIFIED_DESC;
}
let searchParmQuery = '';
if (sortByParams) {
searchParmQuery = sortByParams;
} else if (!nextPageToken) {
const searchParams: SearchParams = {
_text: searchText, // Search text
_elements: 'text,identifier', // Properties to fetch
_count: pageSize,
_sort: sort,
};
searchParmQuery =
Object.entries(searchParams)
.reduce((prev, curr) => `${prev}&${curr.join('=')}`, '');
} else {
searchParmQuery = this.parseUrlParams(nextPageToken);
}
const resultUrl =
`${environment.FHIR_STORE_BASE_URL}DiagnosticReport?${searchParmQuery}${environment.FHIR_STORE_SEARCH_QUERY_PARAMETERS}`;
return resultUrl;
}
private parseUrlParams(token: string): string {
const delimiter = 'DiagnosticReport/?';
const parsedToken =
token.slice(token.indexOf(delimiter) + delimiter.length);
return parsedToken;
}
private resetSearchResults(): void {
this.searchResults$.next(undefined);
this.nextDiagnosticReportLink$.next('');
this.totalDiagnosticReports$.next(0);
}
private sanitizeDiagnosticReports(
diagnosticReports: DiagnosticReport[],
keywords: Set<string>,
): DiagnosticReport[] {
// Sanitize display text notes
return diagnosticReports.map((diagnosticReport: DiagnosticReport) => {
const reportText = diagnosticReport.resource.text;
if (reportText) {
reportText.sanitizedHtml = reportText.div;
reportText.tagsRemovedSanitized = this.windowService.extractContent(reportText.sanitizedHtml);
reportText.snippet = textToSnippet(
keywords, reportText.tagsRemovedSanitized,
(DEFAULT_SNIPPET_LENGTH));
}
diagnosticReport.resource.caseAccessionId =
(diagnosticReport.resource?.identifier ?? [])[0]?.value ?? '';
return diagnosticReport;
});
}
private updateDiagnosticReportSearchResults(
modifiedSearchResults: FhirSearchResults,
nextPageToken?: string,
): void {
if (nextPageToken) {
const prevDiagnosticReports = this.diagnosticReports$.getValue();
this.diagnosticReports$.next(
[...prevDiagnosticReports, ...modifiedSearchResults.entry]);
} else {
this.diagnosticReports$.next(modifiedSearchResults.entry);
this.totalDiagnosticReports$.next(modifiedSearchResults.total);
}
this.searchResults$.next(modifiedSearchResults);
const nextPageLink =
modifiedSearchResults.link
.find(
({ relation }) => relation === DiagnosticReportLinkRelation.NEXT)
?.url ??
'';
this.nextDiagnosticReportLink$.next(nextPageLink);
}
}