From f4c8a15c3d6d20a8ada38b5c4f51be2abef4f1a0 Mon Sep 17 00:00:00 2001 From: c-schuler Date: Thu, 19 Sep 2024 15:20:16 -0600 Subject: [PATCH 1/2] Added POST search capability for prefetch queries This is usually handled by the receiving FHIR server when the URL is too long, but not always. --- src/retrieve-data-helpers/service-exchange.js | 379 ++++++++++-------- 1 file changed, 202 insertions(+), 177 deletions(-) diff --git a/src/retrieve-data-helpers/service-exchange.js b/src/retrieve-data-helpers/service-exchange.js index 4460e1d..161ba84 100644 --- a/src/retrieve-data-helpers/service-exchange.js +++ b/src/retrieve-data-helpers/service-exchange.js @@ -2,30 +2,30 @@ import axios from 'axios'; import queryString from 'query-string'; import retrieveLaunchContext from './launch-context-retrieval'; import { - storeExchange, - storeLaunchContext, + storeExchange, + storeLaunchContext, } from '../actions/service-exchange-actions'; -import { productionClientId, allScopes } from '../config/fhir-config'; +import {productionClientId, allScopes} from '../config/fhir-config'; import generateJWT from './jwt-generator'; const uuidv4 = require('uuid/v4'); const remapSmartLinks = ({ - dispatch, - cardResponse, - fhirAccessToken, - patientId, - fhirServerUrl, -}) => { - ((cardResponse && cardResponse.cards) || []) - .flatMap((card) => card.links || []) - .filter(({ type }) => type === 'smart') - .forEach((link) => retrieveLaunchContext( - link, - fhirAccessToken, - patientId, - fhirServerUrl, - ).catch((e) => e).then((newLink) => dispatch(storeLaunchContext(newLink)))); + dispatch, + cardResponse, + fhirAccessToken, + patientId, + fhirServerUrl, + }) => { + ((cardResponse && cardResponse.cards) || []) + .flatMap((card) => card.links || []) + .filter(({type}) => type === 'smart') + .forEach((link) => retrieveLaunchContext( + link, + fhirAccessToken, + patientId, + fhirServerUrl, + ).catch((e) => e).then((newLink) => dispatch(storeLaunchContext(newLink)))); }; /** @@ -34,17 +34,17 @@ const remapSmartLinks = ({ * @returns {*} - String URL with its query parameters URI encoded if necessary */ function encodeUriParameters(template) { - if (template && template.split('?').length > 1) { - const splitUrl = template.split('?'); - const queryParams = queryString.parse(splitUrl[1]); - Object.keys(queryParams).forEach((param) => { - const val = queryParams[param]; - queryParams[param] = encodeURIComponent(val); - }); - splitUrl[1] = queryString.stringify(queryParams, { encode: false }); - return splitUrl.join('?'); - } - return template; + if (template && template.split('?').length > 1) { + const splitUrl = template.split('?'); + const queryParams = queryString.parse(splitUrl[1]); + Object.keys(queryParams).forEach((param) => { + const val = queryParams[param]; + queryParams[param] = encodeURIComponent(val); + }); + splitUrl[1] = queryString.stringify(queryParams, {encode: false}); + return splitUrl.join('?'); + } + return template; } /** @@ -53,20 +53,20 @@ function encodeUriParameters(template) { * @returns {*} - New prefetch key/value pair Object with prefetch template filled out */ function completePrefetchTemplate(state, prefetch) { - const patient = state.patientState.currentPatient.id; - const user = state.patientState.currentUser || state.patientState.defaultUser; - const prefetchRequests = { ...prefetch }; - Object.keys(prefetchRequests).forEach((prefetchKey) => { - let prefetchTemplate = prefetchRequests[prefetchKey]; - prefetchTemplate = prefetchTemplate.replace( - /{{\s*context\.patientId\s*}}/g, - patient, - ); - prefetchTemplate = prefetchTemplate.replace(/{{\s*user\s*}}/g, user); - prefetchTemplate = prefetchTemplate.replace(/{{\s*context\.userId\s*}}/g, user); - prefetchRequests[prefetchKey] = encodeUriParameters(prefetchTemplate); - }); - return prefetchRequests; + const patient = state.patientState.currentPatient.id; + const user = state.patientState.currentUser || state.patientState.defaultUser; + const prefetchRequests = {...prefetch}; + Object.keys(prefetchRequests).forEach((prefetchKey) => { + let prefetchTemplate = prefetchRequests[prefetchKey]; + prefetchTemplate = prefetchTemplate.replace( + /{{\s*context\.patientId\s*}}/g, + patient, + ); + prefetchTemplate = prefetchTemplate.replace(/{{\s*user\s*}}/g, user); + prefetchTemplate = prefetchTemplate.replace(/{{\s*context\.userId\s*}}/g, user); + prefetchRequests[prefetchKey] = encodeUriParameters(prefetchTemplate); + }); + return prefetchRequests; } /** @@ -76,52 +76,77 @@ function completePrefetchTemplate(state, prefetch) { * @returns {Promise} - Promise object to eventually fetch data */ function prefetchDataPromises(state, baseUrl, prefetch) { - const resultingPrefetch = {}; - const prefetchRequests = { - - ...completePrefetchTemplate(state, prefetch), - }; - return new Promise((resolve) => { - const prefetchKeys = Object.keys(prefetchRequests); - const headers = { Accept: 'application/json+fhir' }; - const accessTokenProperty = state.fhirServerState.accessToken; - if (accessTokenProperty && accessTokenProperty.access_token) { - headers.Authorization = `Bearer ${accessTokenProperty.access_token}`; - } - // Keep count of resolved promises and invoke final resolve when we have them all. NOTE: This can also be - // implemented with Promise.all(), but since we wan't to swallow errors, using Promise.all() ends up being - // more complicated. - let numDone = 0; - const resolveWhenDone = () => { - numDone += 1; - if (numDone === prefetchKeys.length) { - resolve(resultingPrefetch); - } + const resultingPrefetch = {}; + const prefetchRequests = { + + ...completePrefetchTemplate(state, prefetch), }; - for (let i = 0; i < prefetchKeys.length; i += 1) { - const key = prefetchKeys[i]; - const prefetchValue = prefetchRequests[key]; - axios({ - method: 'get', - url: `${baseUrl}/${prefetchValue}`, - headers, - }) - .then((result) => { - if (result.data && Object.keys(result.data).length) { - resultingPrefetch[key] = result.data; - } - resolveWhenDone(); - }) - .catch((err) => { - // Since prefetch is best-effort, don't throw; just log it and continue - console.log( - `Unable to prefetch data for ${baseUrl}/${prefetchValue}`, - err, - ); - resolveWhenDone(); - }); - } - }); + return new Promise((resolve) => { + const prefetchKeys = Object.keys(prefetchRequests); + const headers = {Accept: 'application/json+fhir'}; + const accessTokenProperty = state.fhirServerState.accessToken; + if (accessTokenProperty && accessTokenProperty.access_token) { + headers.Authorization = `Bearer ${accessTokenProperty.access_token}`; + } + // Keep count of resolved promises and invoke final resolve when we have them all. NOTE: This can also be + // implemented with Promise.all(), but since we want to swallow errors, using Promise.all() ends up being + // more complicated. + let numDone = 0; + const resolveWhenDone = () => { + numDone += 1; + if (numDone === prefetchKeys.length) { + resolve(resultingPrefetch); + } + }; + for (let i = 0; i < prefetchKeys.length; i += 1) { + const key = prefetchKeys[i]; + const prefetchValue = prefetchRequests[key]; + if (prefetchValue.length > 2000) { + const resource = prefetchValue.split('?')[0]; // TODO: investigate edge cases + const params = new URLSearchParams(prefetchValue.split('?')[1]); + axios({ + method: 'POST', + headers: {'content-type': 'application/x-www-form-urlencoded'}, + data: params.toString(), + url: `${baseUrl}/${resource}/_search` + }) + .then((result) => { + if (result.data && Object.keys(result.data).length) { + resultingPrefetch[key] = result.data; + } + resolveWhenDone(); + }) + .catch((err) => { + // Since prefetch is best-effort, don't throw; just log it and continue + console.log( + `Unable to prefetch data for ${baseUrl}/${prefetchValue}`, + err, + ); + resolveWhenDone(); + }); + } else { + axios({ + method: 'get', + url: `${baseUrl}/${prefetchValue}`, + headers, + }) + .then((result) => { + if (result.data && Object.keys(result.data).length) { + resultingPrefetch[key] = result.data; + } + resolveWhenDone(); + }) + .catch((err) => { + // Since prefetch is best-effort, don't throw; just log it and continue + console.log( + `Unable to prefetch data for ${baseUrl}/${prefetchValue}`, + err, + ); + resolveWhenDone(); + }); + } + } + }); } /** @@ -133,99 +158,99 @@ function prefetchDataPromises(state, baseUrl, prefetch) { * @returns {Promise} - Promise object to eventually return service response data */ function callServices(dispatch, state, url, context, exchangeRound = 0) { - const hook = state.hookState.currentHook; - const fhirServer = state.fhirServerState.currentFhirServer; + const hook = state.hookState.currentHook; + const fhirServer = state.fhirServerState.currentFhirServer; - const activityContext = {}; - activityContext.patientId = state.patientState.currentPatient.id; - activityContext.userId = state.patientState.currentUser || state.patientState.defaultUser; + const activityContext = {}; + activityContext.patientId = state.patientState.currentPatient.id; + activityContext.userId = state.patientState.currentUser || state.patientState.defaultUser; - if (context && context.length) { - context.forEach((contextKey) => { - activityContext[contextKey.key] = contextKey.value; - }); - } - - const hookInstance = uuidv4(); - const accessTokenProperty = state.fhirServerState.accessToken; - let fhirAuthorization; - if (accessTokenProperty) { - fhirAuthorization = { - access_token: accessTokenProperty.access_token, - token_type: 'Bearer', - expires_in: accessTokenProperty.expires_in, - scope: allScopes, - subject: productionClientId, - }; - } - const request = { - hookInstance, - hook, - fhirServer, - context: activityContext, - }; - - if (fhirAuthorization) { - request.fhirAuthorization = fhirAuthorization; - } - - const serviceDefinition = state.cdsServicesState.configuredServices[url]; - - const sendRequest = () => axios({ - method: 'post', - url, - data: request, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${generateJWT(url)}`, - }, - }); - - const dispatchResult = (result) => { - if (result.data && Object.keys(result.data).length) { - dispatch(storeExchange(url, request, result.data, result.status, exchangeRound)); - remapSmartLinks({ - dispatch, - cardResponse: result.data, - fhirAccessToken: state.fhirServerState.accessToken, - patientId: state.patientState.currentPatient.id, - fhirServerUrl: state.fhirServerState.currentFhirServer, - }); - } else { - dispatch(storeExchange( - url, - request, - 'No response returned. Check developer tools for more details.', - )); + if (context && context.length) { + context.forEach((contextKey) => { + activityContext[contextKey.key] = contextKey.value; + }); } - }; - - const dispatchErrors = (err) => { - console.error(`Could not POST data to CDS Service ${url}`, err); - dispatch(storeExchange( - url, - request, - 'Could not get a response from the CDS Service. ' - + 'See developer tools for more details', - )); - }; - - // Wait for prefetch to be fulfilled before making a request to the CDS service, if the service has prefetch expectations - const needPrefetch = serviceDefinition.prefetch - && Object.keys(serviceDefinition.prefetch).length > 0; - - const prefetchPromise = needPrefetch - ? prefetchDataPromises(state, fhirServer, serviceDefinition.prefetch) - : Promise.resolve({}); - - return prefetchPromise.then((prefetchResults) => { - if (prefetchResults && Object.keys(prefetchResults).length > 0) { - request.prefetch = prefetchResults; + + const hookInstance = uuidv4(); + const accessTokenProperty = state.fhirServerState.accessToken; + let fhirAuthorization; + if (accessTokenProperty) { + fhirAuthorization = { + access_token: accessTokenProperty.access_token, + token_type: 'Bearer', + expires_in: accessTokenProperty.expires_in, + scope: allScopes, + subject: productionClientId, + }; } - return sendRequest() - .then(dispatchResult) - .catch(dispatchErrors); - }); + const request = { + hookInstance, + hook, + fhirServer, + context: activityContext, + }; + + if (fhirAuthorization) { + request.fhirAuthorization = fhirAuthorization; + } + + const serviceDefinition = state.cdsServicesState.configuredServices[url]; + + const sendRequest = () => axios({ + method: 'post', + url, + data: request, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${generateJWT(url)}`, + }, + }); + + const dispatchResult = (result) => { + if (result.data && Object.keys(result.data).length) { + dispatch(storeExchange(url, request, result.data, result.status, exchangeRound)); + remapSmartLinks({ + dispatch, + cardResponse: result.data, + fhirAccessToken: state.fhirServerState.accessToken, + patientId: state.patientState.currentPatient.id, + fhirServerUrl: state.fhirServerState.currentFhirServer, + }); + } else { + dispatch(storeExchange( + url, + request, + 'No response returned. Check developer tools for more details.', + )); + } + }; + + const dispatchErrors = (err) => { + console.error(`Could not POST data to CDS Service ${url}`, err); + dispatch(storeExchange( + url, + request, + 'Could not get a response from the CDS Service. ' + + 'See developer tools for more details', + )); + }; + + // Wait for prefetch to be fulfilled before making a request to the CDS service, if the service has prefetch expectations + const needPrefetch = serviceDefinition.prefetch + && Object.keys(serviceDefinition.prefetch).length > 0; + + const prefetchPromise = needPrefetch + ? prefetchDataPromises(state, fhirServer, serviceDefinition.prefetch) + : Promise.resolve({}); + + return prefetchPromise.then((prefetchResults) => { + if (prefetchResults && Object.keys(prefetchResults).length > 0) { + request.prefetch = prefetchResults; + } + return sendRequest() + .then(dispatchResult) + .catch(dispatchErrors); + }); } export default callServices; From 496ab1a45058a9ab5d2a3aa78acadad3a2f34aab Mon Sep 17 00:00:00 2001 From: c-schuler Date: Fri, 8 Nov 2024 09:47:31 -0700 Subject: [PATCH 2/2] Updated prefetch strategy to first attempt search using POST - only using GET when POST results in an error --- src/retrieve-data-helpers/service-exchange.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/retrieve-data-helpers/service-exchange.js b/src/retrieve-data-helpers/service-exchange.js index 161ba84..83181fd 100644 --- a/src/retrieve-data-helpers/service-exchange.js +++ b/src/retrieve-data-helpers/service-exchange.js @@ -101,7 +101,8 @@ function prefetchDataPromises(state, baseUrl, prefetch) { for (let i = 0; i < prefetchKeys.length; i += 1) { const key = prefetchKeys[i]; const prefetchValue = prefetchRequests[key]; - if (prefetchValue.length > 2000) { + let usePost = false; + if (i === 0 || usePost) { const resource = prefetchValue.split('?')[0]; // TODO: investigate edge cases const params = new URLSearchParams(prefetchValue.split('?')[1]); axios({ @@ -114,17 +115,19 @@ function prefetchDataPromises(state, baseUrl, prefetch) { if (result.data && Object.keys(result.data).length) { resultingPrefetch[key] = result.data; } + usePost = true; resolveWhenDone(); }) .catch((err) => { // Since prefetch is best-effort, don't throw; just log it and continue console.log( - `Unable to prefetch data for ${baseUrl}/${prefetchValue}`, + `Unable to prefetch data using POST for ${baseUrl}/${prefetchValue}`, err, ); resolveWhenDone(); }); - } else { + } + if (!usePost) { axios({ method: 'get', url: `${baseUrl}/${prefetchValue}`,