Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CMH Integration #341

Merged
merged 6 commits into from
May 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,13 @@ G4RD_AUTH_METHOD=bearer
G4RD_TOKEN_URL=https://some-provider.example.com
G4RD_AUTH0_BASE_URL=https://some-subdomain.auth0.com

CMH_AZURE_CLIENT_ID=replacethis_id
CMH_AZURE_CLIENT_SECRET=replacethis_secret
CMH_TOKEN_URL=https://replacethis.fakeurl
CMH_RESOURCE=replacethis_id
CMH_SCOPE=https://replacethis.fakeurl/something
CMH_GRANT_TYPE=client_credentials
CMH_GENE42_SECRET=replacethis_secret
CMH_URL=https://replacethis.fakeurl

SERVER_SESSION_SECRET=secret
8 changes: 8 additions & 0 deletions .github/workflows/node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ jobs:
G4RD_GRANT_TYPE: ${{ secrets.G4RD_GRANT_TYPE }}
G4RD_TOKEN_URL: ${{ secrets.G4RD_TOKEN_URL }}
G4RD_CLIENT_ID: ${{ secrets.G4RD_CLIENT_ID }}
CMH_AZURE_CLIENT_ID: ${{ secrets.CMH_AZURE_CLIENT_ID }}
CMH_AZURE_CLIENT_SECRET: ${{ secrets.CMH_AZURE_CLIENT_SECRET }}
CMH_TOKEN_URL: ${{ secrets.CMH_TOKEN_URL }}
CMH_RESOURCE: ${{ secrets.CMH_RESOURCE }}
CMH_SCOPE: ${{ secrets.CMH_SCOPE }}
CMH_GRANT_TYPE: ${{ secrets.CMH_GRANT_TYPE }}
CMH_GENE42_SECRET: ${{ secrets.CMH_GENE42_SECRET }}
CMH_URL: ${{ secrets.CMH_URL }}
KEYCLOAK_AUTH_URL: ${{ secrets.KEYCLOAK_AUTH_URL }}
KEYCLOAK_REALM: ${{ secrets.KEYCLOAK_REALM }}
KEYCLOAK_CLIENT_ID: ${{ secrets.KEYCLOAK_CLIENT_ID }}
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ services:
G4RD_GRANT_TYPE:
G4RD_TOKEN_URL:
G4RD_CLIENT_ID:
CMH_AZURE_CLIENT_ID:
CMH_AZURE_CLIENT_SECRET:
CMH_TOKEN_URL:
CMH_RESOURCE:
CMH_SCOPE:
CMH_GRANT_TYPE:
CMH_GENE42_SECRET:
CMH_URL:
KEYCLOAK_AUTH_URL:
KEYCLOAK_CLIENT_ID:
KEYCLOAK_REALM:
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ services:
G4RD_TOKEN_URL:
G4RD_URL:
G4RD_USERNAME:
CMH_AZURE_CLIENT_ID:
CMH_AZURE_CLIENT_SECRET:
CMH_TOKEN_URL:
CMH_RESOURCE:
CMH_SCOPE:
CMH_GRANT_TYPE:
CMH_GENE42_SECRET:
CMH_URL:
KEYCLOAK_AUTH_URL:
KEYCLOAK_REALM:
KEYCLOAK_CLIENT_ID: ${KEYCLOAK_SERVER_CLIENT_ID}
Expand Down
2 changes: 1 addition & 1 deletion docs/production.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Production

OSMP is deployed via Docker Compose at [https://osmp.genomics4rd.ca](https://osmp.genomics4rd.ca). The key differences between the staging and production are the removal of the test node and increased memory and CPU resource allocations for the `server` container. The OSMP production instance is connected to the [production instance of G4RD Phenotips](https://phenotips.genomics4rd.ca). Similar to the staging stack, to deploy the frontend, compiled static bundles are uploaded to a designated MinIO bucket. The routing between the frontend and backend is handled by the HAProxy reverse proxy. User accounts are managed in [Keycloak](https://keycloak.genomics4rd.ca).
OSMP is deployed via Docker Compose at [https://osmp.genomics4rd.ca](https://osmp.genomics4rd.ca). The key differences between the staging and production are the removal of the test node and increased memory and CPU resource allocations for the `server` container. The OSMP production instance is connected to the [production instance of G4RD Phenotips](https://phenotips.genomics4rd.ca) and to the [production instance of CMH Phenotips](https://phenotips-ga4k.cmh.edu). Similar to the staging stack, to deploy the frontend, compiled static bundles are uploaded to a designated MinIO bucket. The routing between the frontend and backend is handled by the HAProxy reverse proxy. User accounts are managed in [Keycloak](https://keycloak.genomics4rd.ca).

## Continuous deployment through Github Actions

Expand Down
2 changes: 1 addition & 1 deletion docs/staging.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Staging

OSMP is deployed via Docker Compose at [https://osmp.ccmdev.ca/](https://osmp.ccmdev.ca/). For G4RD data source, the OSMP staging instance is connected to the [staging instance of G4RD Phenotips](https://staging.phenotips.genomics4rd.ca). To deploy the frontend, compiled static bundles are uploaded to a designated MinIO bucket. The routing between the frontend and backend is handled by the HAProxy reverse proxy. User accounts are managed in [Keycloak](https://keycloak.ccmdev.ca).
OSMP is deployed via Docker Compose at [https://osmp.ccmdev.ca/](https://osmp.ccmdev.ca/). For G4RD data source, the OSMP staging instance is connected to the [staging instance of G4RD Phenotips](https://staging.phenotips.genomics4rd.ca) and the [staging instance of CMH Phenotips](https://phenotipstest-ga4k.cmh.edu). To deploy the frontend, compiled static bundles are uploaded to a designated MinIO bucket. The routing between the frontend and backend is handled by the HAProxy reverse proxy. User accounts are managed in [Keycloak](https://keycloak.ccmdev.ca).

## Continuous deployment through Github Actions

Expand Down
12 changes: 12 additions & 0 deletions react/src/utils/tableHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,18 @@ export const prepareData = (
row.maleCount = currMaleCount;
});

// update individualId, familyId with source prefix
// (P0001 from G4RD is not the same as P0001 from CMH)
result.map(r => {
if (r.individualId) {
r.individualId = [r.source, r.individualId].join('_');
}
if (r.familyId) {
r.familyId = [r.source, r.familyId].join('_');
}
return r;
});

// Remove duplicate variants for the same patient
const uniquePatientVariants = result.filter(
(arr, index, self) =>
Expand Down
303 changes: 303 additions & 0 deletions server/src/resolvers/getVariantsResolver/adapters/cmhAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
import axios, { AxiosError, AxiosResponse } from 'axios';
import jwtDecode from 'jwt-decode';
import { URLSearchParams } from 'url';
import { v4 as uuidv4 } from 'uuid';
import logger from '../../../logger';
import {
ErrorTransformer,
IndividualResponseFields,
QueryInput,
ResultTransformer,
VariantQueryResponse,
VariantResponseFields,
G4RDFamilyQueryResult,
G4RDPatientQueryResult,
G4RDVariantQueryResult,
Disorder,
IndividualInfoFields,
PhenotypicFeaturesFields,
NonStandardFeature,
Feature,
} from '../../../types';
import { getFromCache, putInCache } from '../../../utils/cache';
import { timeit, timeitAsync } from '../../../utils/timeit';
import resolveAssembly from '../utils/resolveAssembly';

/* eslint-disable camelcase */

/**
* CMH's PhenoTips instance should have the same format as G4RD.
* However, there's a different process in place for accessing it:
* - Request access token from Azure,
* - Provide token and Gene42 secret when querying CMH PT.
*/

const SOURCE_NAME = 'cmh';
const AZURE_BEARER_CACHE_KEY = 'cmhToken';

type CMHNodeQueryError = AxiosError<string>;

/**
* @param args VariantQueryInput
* @returns Promise<ResolvedVariantQueryResult>
*/
const _getCMHNodeQuery = async ({
input: { gene: geneInput, variant },
}: QueryInput): Promise<VariantQueryResponse> => {
let CMHNodeQueryError: CMHNodeQueryError | null = null;
let CMHVariantQueryResponse: null | AxiosResponse<G4RDVariantQueryResult> = null;
let CMHPatientQueryResponse: null | AxiosResponse<G4RDPatientQueryResult> = null;
const FamilyIds: null | Record<string, string> = {}; // <PatientId, FamilyId>
let Authorization = '';
try {
Authorization = await getAuthHeader();
} catch (e: any) {
logger.error(e);
logger.error(JSON.stringify(e?.response?.data));
return {
data: [],
error: { code: 403, message: 'ERROR FETCHING OAUTH TOKEN', id: uuidv4() },
source: SOURCE_NAME,
};
}
const url = `${process.env.CMH_URL}/rest/variants/match`;
/* eslint-disable @typescript-eslint/no-unused-vars */
const { position, ...gene } = geneInput;
variant.assemblyId = 'GRCh37';
try {
CMHVariantQueryResponse = await axios.post<G4RDVariantQueryResult>(
url,
{
gene,
variant,
},
{
headers: {
Authorization,
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Gene42-Secret': `${process.env.CMH_GENE42_SECRET}`, //
},
}
);

// Get patients info
if (CMHVariantQueryResponse) {
let individualIds = CMHVariantQueryResponse.data.results
.map(v => v.individual.individualId!)
.filter(Boolean); // Filter out undefined and null values.

// Get all unique individual Ids.
individualIds = [...new Set(individualIds)];

if (individualIds.length > 0) {
const patientUrl = `${process.env.CMH_URL}/rest/patients/fetch?${individualIds
.map(id => `id=${id}`)
.join('&')}`;

CMHPatientQueryResponse = await axios.get<G4RDPatientQueryResult>(
new URL(patientUrl).toString(),
{
headers: {
Authorization,
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Gene42-Secret': `${process.env.CMH_GENE42_SECRET}`,
},
}
);

// Get Family Id for each patient.
const patientFamily = axios.create({
headers: {
Authorization,
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Gene42-Secret': `${process.env.CMH_GENE42_SECRET}`,
},
});

const familyResponses = await Promise.allSettled(
individualIds.map(id =>
patientFamily.get<G4RDFamilyQueryResult>(
new URL(`${process.env.CMH_URL}/rest/patients/${id}/family`).toString()
)
)
);

familyResponses.forEach((response, index) => {
if (response.status === 'fulfilled' && response.value.status === 200) {
FamilyIds[individualIds[index]] = response.value.data.id;
}
});
}
}
} catch (e: any) {
logger.error(e);
CMHNodeQueryError = e;
}

return {
data: transformCMHQueryResponse(
(CMHVariantQueryResponse?.data as G4RDVariantQueryResult) || [],
(CMHPatientQueryResponse?.data as G4RDPatientQueryResult) || [],
FamilyIds
),
error: transformCMHNodeErrorResponse(CMHNodeQueryError),
source: SOURCE_NAME,
};
};

/**
* @param args VariantQueryInput
* @returns Promise<ResolvedVariantQueryResult>
*/
const getCMHNodeQuery = timeitAsync('getCMHNodeQuery')(_getCMHNodeQuery);

const getAuthHeader = async () => {
const {
CMH_AZURE_CLIENT_ID: client_id,
CMH_AZURE_CLIENT_SECRET: client_secret,
CMH_TOKEN_URL,
CMH_RESOURCE: resource,
CMH_SCOPE: scope,
CMH_GRANT_TYPE: grant_type,
} = process.env;
const cachedToken = getFromCache(AZURE_BEARER_CACHE_KEY);
if (cachedToken) {
return `Bearer ${cachedToken}`;
}

const params = new URLSearchParams({
client_id,
client_secret,
resource,
scope,
grant_type,
} as Record<string, string>);

const tokenResponse = await axios.post<{ access_token: string }>(CMH_TOKEN_URL!, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: '*/*' },
});
const token = tokenResponse.data.access_token;
const decoded = jwtDecode<{ iat: number; exp: number }>(token);
const ttl = decoded.exp - Date.now() / 1000;
putInCache(AZURE_BEARER_CACHE_KEY, token, ttl);
return `Bearer ${token}`;
};

export const transformCMHNodeErrorResponse: ErrorTransformer<CMHNodeQueryError> = error => {
if (!error) {
return undefined;
} else {
return {
id: uuidv4(),
code: error.response?.status || 500,
message:
error.response?.status === 404
? 'No variants found matching your query.'
: error.response?.statusText,
};
}
};

const isObserved = (feature: Feature | NonStandardFeature) =>
feature.observed === 'yes' ? true : feature.observed === 'no' ? false : undefined;

export const transformCMHQueryResponse: ResultTransformer<G4RDVariantQueryResult> = timeit(
'transformCMHQueryResponse'
)(
(
variantResponse: G4RDVariantQueryResult,
patientResponse: G4RDPatientQueryResult[],
familyIds: Record<string, string>
) => {
const individualIdsMap = Object.fromEntries(patientResponse.map(p => [p.id, p]));

return (variantResponse.results || []).map(r => {
/* eslint-disable @typescript-eslint/no-unused-vars */
r.variant.assemblyId = resolveAssembly(r.variant.assemblyId);
const { individual, contactInfo } = r;

const patient = individual.individualId ? individualIdsMap[individual.individualId] : null;

let info: IndividualInfoFields = {};
let ethnicity: string = '';
let disorders: Disorder[] = [];
let phenotypicFeatures: PhenotypicFeaturesFields[] = individual.phenotypicFeatures || [];

if (patient) {
const candidateGene = (patient.genes ?? []).map(g => g.gene).join('\n');
const classifications = (patient.genes ?? []).map(g => g.status).join('\n');
const diagnosis = patient.clinicalStatus;
const solved = patient.solved ? patient.solved.status : '';
const clinicalStatus = patient.clinicalStatus;
disorders = patient.disorders.filter(({ label }) => label !== 'affected') as Disorder[];
ethnicity = Object.values(patient.ethnicity)
.flat()
.map(p => p.trim())
.join(', ');
info = {
solved,
candidateGene,
diagnosis,
classifications,
clinicalStatus,
disorders,
};
// variant response contains all phenotypic features listed,
// even if some of them are explicitly _not_ observed by clinician and recorded as such
if (individual.phenotypicFeatures !== null && individual.phenotypicFeatures !== undefined) {
const features = [...(patient.features ?? []), ...(patient.nonstandard_features ?? [])];
const detailedFeatures = individual.phenotypicFeatures;
// build list of features the safe way
const detailedFeatureMap = Object.fromEntries(
detailedFeatures.map(feat => [feat.phenotypeId, feat])
);
const finalFeatures: PhenotypicFeaturesFields[] = features.map(feat => {
if (feat.id === undefined) {
return {
ageOfOnset: null,
dateOfOnset: null,
levelSeverity: null,
onsetType: null,
phenotypeId: feat.id,
phenotypeLabel: feat.label,
observed: isObserved(feat),
};
}
return {
...detailedFeatureMap[feat.id],
observed: isObserved(feat),
};
});
phenotypicFeatures = finalFeatures;
}
}

const variant: VariantResponseFields = {
alt: r.variant.alt,
assemblyId: r.variant.assemblyId,
callsets: r.variant.callsets,
end: r.variant.end,
ref: r.variant.ref,
start: r.variant.start,
chromosome: r.variant.chromosome,
};

let familyId: string = '';
if (individual.individualId) familyId = familyIds[individual.individualId];

const individualResponseFields: IndividualResponseFields = {
...individual,
ethnicity,
info,
familyId,
phenotypicFeatures,
};
return { individual: individualResponseFields, variant, contactInfo, source: SOURCE_NAME };
});
}
);

export default getCMHNodeQuery;
Loading