adding Base64 methods

Adding tests for BaseClient
Adding Fhir401Client
Adding fixtures for BaseClient and Fhir401Client
This commit is contained in:
Jason Kulatunga 2022-10-05 22:01:23 -07:00
parent 0a5d71691f
commit 6425ea48f0
17 changed files with 19179 additions and 32 deletions

View File

@ -6,3 +6,6 @@ Find & replace the following
- `fastenhealth` - find and replace this with your binary name - `fastenhealth` - find and replace this with your binary name
- make sure you rename the folder as well. - make sure you rename the folder as well.
# Running tests
- ng test --include='**/base_client.spec.ts'

View File

@ -1,6 +1,8 @@
import {BaseClient} from './base_client'; import {BaseClient} from './base_client';
import {Source} from '../../../models/database/source'; import {Source} from '../../../models/database/source';
import * as BaseClient_GetRequest from './fixtures/BaseClient_GetRequest.json';
import * as BaseClient_GetFhirVersion from './fixtures/BaseClient_GetFhirVersion.json';
class TestClient extends BaseClient { class TestClient extends BaseClient {
constructor(source: Source) { constructor(source: Source) {
@ -13,25 +15,19 @@ describe('BaseClient', () => {
beforeEach(async () => { beforeEach(async () => {
client = new TestClient(new Source({ client = new TestClient(new Source({
"authorization_endpoint": "https://auth.logicahealth.org/authorize", "authorization_endpoint": "https://fhirsandbox.healthit.gov/open/r4/authorize",
"token_endpoint": "https://auth.logicahealth.org/token", "token_endpoint": "https://fhirsandbox.healthit.gov/open/r4/token",
"introspection_endpoint": "https://auth.logicahealth.org/introspect", "introspection_endpoint": "",
"userinfo_endpoint": "", "issuer": "https://fhirsandbox.healthit.go",
"scopes_supported": ["openid", "fhirUser", "patient/*.read", "offline_access"], "api_endpoint_base_url": "https://fhirsandbox.healthit.gov/secure/r4/fhir",
"issuer": "https://auth.logicahealth.org", "client_id": "9ad3ML0upIMiawLVdM5-DiPinGcv7M",
"grant_types_supported": ["authorization_code"], "redirect_uri": "https://lighthouse.fastenhealth.com/sandbox/callback/healthit",
"response_types_supported": ["code"],
"aud": "",
"code_challenge_methods_supported": ["S256"],
"api_endpoint_base_url": "https://api.logicahealth.org/fastenhealth/data",
"client_id": "12b14c49-a4da-42f7-9e6f-2f19db622962",
"redirect_uri": "https://lighthouse.fastenhealth.com/sandbox/callback/logica",
"confidential": false, "confidential": false,
"source_type": "logica", "source_type": "healthit",
"patient": "smart-1288992", "patient": "placeholder",
"access_token": "xxx.xxx.xxx", "access_token": "2e1be8c72d4d5225aae264a1fb7e1d3e",
"refresh_token": "xxx.xxx.", "refresh_token": "",
"expires_at": 1664949030, "expires_at": 16649837100, //aug 11, 2497 (for testing)
})); }));
}); });
@ -41,14 +37,35 @@ describe('BaseClient', () => {
describe('GetRequest', () => { describe('GetRequest', () => {
it('should make an authorized request', async () => { it('should make an authorized request', async () => {
const resp = await client.GetRequest("Patient/smart-1288992")
expect(resp).toEqual({ //setup
resourceType: "Patient", let response = new Response(JSON.stringify(BaseClient_GetRequest));
id: "source:aetna:patient" Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/Patient/${client.source.patient}`});
}); spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
//test
const resp = await client.GetRequest(`Patient/${client.source.patient}`)
//expect
expect(resp.resourceType).toEqual("Patient");
expect(resp.id).toEqual("123d41e1-0f71-4e9f-8eb2-d1b1330201a6");
}); });
}) })
describe('GetFhirVersion', () => {
it('should make an authorized request', async () => {
//setup
let response = new Response(JSON.stringify(BaseClient_GetFhirVersion));
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/metadata`});
spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
//test
const resp = await client.GetFhirVersion()
//expect
expect(resp).toEqual("4.0.1");
});
});
}) })

View File

@ -1,6 +1,6 @@
import {Source} from '../../../models/database/source'; import {Source} from '../../../models/database/source';
import * as Oauth from '@panva/oauth4webapi'; import * as Oauth from '@panva/oauth4webapi';
import {IResourceInterface} from '../../interface'; import {IResourceRaw} from '../../interface';
// BaseClient is an abstract/partial class, its intended to be used by FHIR clients, and generically handle OAuth requests. // BaseClient is an abstract/partial class, its intended to be used by FHIR clients, and generically handle OAuth requests.
export abstract class BaseClient { export abstract class BaseClient {
@ -26,8 +26,14 @@ export abstract class BaseClient {
} }
} }
public async GetFhirVersion(): Promise<any> {
return this.GetRequest("metadata")
.then((resp) => {
return resp.fhirVersion
})
}
public async GetRequest(resourceSubpathOrNext: string): Promise<IResourceInterface> { public async GetRequest(resourceSubpathOrNext: string): Promise<any> {
//check if the url is absolute //check if the url is absolute
let resourceUrl: string let resourceUrl: string
@ -53,6 +59,22 @@ export abstract class BaseClient {
// return err // return err
} }
/////////////////////////////////////////////////////////////////////////////
// Protected methods
/////////////////////////////////////////////////////////////////////////////
protected GetPatientBundle(patientId: string): Promise<any> {
return this.GetRequest(`Patient/${patientId}/$everything`)
}
protected GetPatient(patientId: string): Promise<IResourceRaw> {
return this.GetRequest(`Patient/${patientId}`)
}
/////////////////////////////////////////////////////////////////////////////
// Private methods
/////////////////////////////////////////////////////////////////////////////
private async refreshExpiredTokenIfRequired(source: Source): Promise<Source> { private async refreshExpiredTokenIfRequired(source: Source): Promise<Source> {
//check if token has expired, and a refreshtoken is available //check if token has expired, and a refreshtoken is available
// Note: source.expires_at is in seconds, Date.now() is in milliseconds. // Note: source.expires_at is in seconds, Date.now() is in milliseconds.

View File

@ -0,0 +1,80 @@
import {FHIR401Client} from './fhir401_r4_client';
import {Source} from '../../../models/database/source';
import * as FHIR401Client_ProcessBundle from './fixtures/FHIR401Client_ProcessBundle.json';
import {IResourceBundleRaw} from '../../interface';
import {ResourceFhir} from '../../../models/database/resource_fhir';
import {NewRepositiory} from '../../../database/pouchdb_repository';
import {Base64} from '../../../utils/base64';
class TestClient extends FHIR401Client {
constructor(source: Source) {
super(source);
}
public async ProcessBundle(bundle: IResourceBundleRaw): Promise<ResourceFhir[]> {
return super.ProcessBundle(bundle);
}
}
describe('FHIR401Client', () => {
let client: TestClient;
beforeEach(async () => {
client = new TestClient(new Source({
"_id": "source:aetna:12345",
"authorization_endpoint": "https://fhirsandbox.healthit.gov/open/r4/authorize",
"token_endpoint": "https://fhirsandbox.healthit.gov/open/r4/token",
"introspection_endpoint": "",
"issuer": "https://fhirsandbox.healthit.go",
"api_endpoint_base_url": "https://fhirsandbox.healthit.gov/secure/r4/fhir",
"client_id": "9ad3ML0upIMiawLVdM5-DiPinGcv7M",
"redirect_uri": "https://lighthouse.fastenhealth.com/sandbox/callback/healthit",
"confidential": false,
"source_type": "healthit",
"patient": "placeholder",
"access_token": "2e1be8c72d4d5225aae264a1fb7e1d3e",
"refresh_token": "",
"expires_at": 16649837100, //aug 11, 2497 (for testing)
}));
});
it('should be created', () => {
expect(client).toBeTruthy();
});
describe('ProcessBundle', () => {
it('should correctly wrap each BundleEntry with ResourceFhir', async () => {
//setup
//test
const resp = await client.ProcessBundle(FHIR401Client_ProcessBundle)
//expect
expect(resp.length).toEqual(206);
expect(resp[0].source_resource_id).toEqual("c088b7af-fc41-43cc-ab80-4a9ab8d47cd9");
expect(resp[0].source_resource_type).toEqual("Patient");
});
})
describe('SyncAll', () => {
it('should correctly add resources to the database', async () => {
//setup
let response = new Response(JSON.stringify(FHIR401Client_ProcessBundle));
Object.defineProperty(response, "url", { value: `${client.source.api_endpoint_base_url}/Patient/${client.source.patient}/$everything`});
spyOn(window, "fetch").and.returnValue(Promise.resolve(response));
const db = NewRepositiory("fastentest")
//test
const resp = await client.SyncAll(db)
const firstResourceFhir = resp[0]
const resourceIdParts = firstResourceFhir.split(":")
//expect
expect(resp.length).toEqual(206);
expect(firstResourceFhir).toEqual('resource_fhir:c291cmNlOmFldG5hOjEyMzQ1:Patient:c088b7af-fc41-43cc-ab80-4a9ab8d47cd9');
expect(Base64.Decode(resourceIdParts[1])).toEqual("source:aetna:12345");
});
})
})

View File

@ -0,0 +1,62 @@
import {IClient, IResourceBundleRaw, IResourceRaw} from '../../interface';
import {BaseClient} from './base_client';
import {Source} from '../../../models/database/source';
import {IDatabaseRepository} from '../../../database/interface';
import {ResourceFhir} from '../../../models/database/resource_fhir';
export class FHIR401Client extends BaseClient implements IClient {
//clients extending this class must validate fhirVersion matches using conformance/metadata url.
fhirVersion = "4.0.1"
constructor(source: Source) {
super(source);
}
public async SyncAll(db: IDatabaseRepository): Promise<string[]> {
const bundle = await this.GetPatientBundle(this.source.patient)
const wrappedResourceModels = await this.ProcessBundle(bundle)
//todo, create the resources in dependency order
//TODO bulk insert
// for(let dbModel of wrappedResourceModels){
// db.CreateResource(dbModel)
// }
console.log(wrappedResourceModels)
return db.CreateResources(wrappedResourceModels)
}
public async SyncAllBundle(db: IDatabaseRepository, bundleFile: any): Promise<any> {
return Promise.resolve(undefined);
}
/////////////////////////////////////////////////////////////////////////////
// Protected methods
/////////////////////////////////////////////////////////////////////////////
protected async ProcessBundle(bundle: IResourceBundleRaw): Promise<ResourceFhir[]> {
// console.log(bundle)
// process each entry in bundle
return bundle.entry
.filter((bundleEntry) => {
return bundleEntry.resource.id // keep this entry if it has an ID, skip otherwise.
})
.map((bundleEntry) => {
const wrappedResourceModel = new ResourceFhir()
wrappedResourceModel.source_id = this.source._id
wrappedResourceModel.source_resource_id = bundleEntry.resource.id
wrappedResourceModel.source_resource_type = bundleEntry.resource.resourceType
wrappedResourceModel.resource_raw = bundleEntry.resource
// TODO find a way to safely/consistently get the resource updated date (and other metadata) which shoudl be added to the model.
// wrappedResourceModel.updated_at = bundleEntry.resource.meta?.lastUpdated
return wrappedResourceModel
})
}
/////////////////////////////////////////////////////////////////////////////
// Private methods
/////////////////////////////////////////////////////////////////////////////
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,159 @@
{
"resourceType": "Patient",
"id": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6",
"meta": {
"versionId": "1.0"
},
"extension": [
{
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race",
"extension": [
{
"url": "ombCategory",
"valueCoding": {
"system": "urn:oid:2.16.840.1.113883.6.238",
"code": "2054-5",
"display": "Black or African American"
}
},
{
"url": "text",
"valueString": "Black or African American"
}
]
},
{
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity",
"extension": [
{
"url": "ombCategory",
"valueCoding": {
"system": "urn:oid:2.16.840.1.113883.6.238",
"code": "2135-2",
"display": "Hispanic or Latino"
}
},
{
"url": "text",
"valueString": "Hispanic or Latino"
}
]
},
{
"url": "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName",
"valueString": "Ramona980 Franco581"
},
{
"url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex",
"valueCode": "M"
},
{
"url": "http://hl7.org/fhir/StructureDefinition/data-absent-reason",
"valueCode": "masked"
}
],
"identifier": [
{
"system": "https://github.com/synthetichealth/synthea",
"value": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6"
},
{
"type": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
"code": "MR",
"display": "Medical Record Number"
}
]
},
"system": "http://hospital.smarthealthit.org",
"value": "123d41e1-0f71-4e9f-8eb2-d1b1330201a6"
},
{
"type": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
"code": "SS",
"display": "Social Security Number"
}
]
},
"system": "http://hl7.org/fhir/sid/us-ssn",
"value": "999-50-6731"
},
{
"type": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/v2-0203",
"code": "DL",
"display": "Drivers License"
}
]
},
"system": "urn:oid:2.16.840.1.113883.4.3.25",
"value": "S99957478"
}
],
"name": [
{
"use": "official",
"family": "Gutiérrez115",
"given": [
"Hugo693"
],
"prefix": [
"Mr."
]
}
],
"telecom": [
{
"system": "phone",
"value": "555-914-6475",
"use": "home"
}
],
"gender": "male",
"birthDate": "1969-01-12",
"address": [
{
"line": [
"[\"293 Douglas Ramp\"]"
],
"city": "Natick",
"state": "MA",
"postalCode": "01999",
"country": "US",
"period": {
"start": "1969-01-12T00:00:00+00:00"
}
}
],
"maritalStatus": {
"coding": [
{
"system": "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus",
"code": "M",
"display": "M"
}
],
"text": "M"
},
"multipleBirthBoolean": false,
"communication": [
{
"language": {
"coding": [
{
"system": "urn:ietf:bcp:47",
"code": "es",
"display": "Spanish"
}
]
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,45 @@
import {IDatabaseRepository} from '../database/interface'; import {IDatabaseRepository} from '../database/interface';
export interface IClient { export interface IClient {
fhirVersion: string
GetRequest(resourceSubpath: string): Promise<any> GetRequest(resourceSubpath: string): Promise<any>
GetFhirVersion(): Promise<any>
SyncAll(db: IDatabaseRepository): Promise<any> SyncAll(db: IDatabaseRepository): Promise<any>
//Manual client ONLY functions //Manual client ONLY functions
SyncAllBundle(db: IDatabaseRepository, bundleFile: any): Promise<any> SyncAllBundle(db: IDatabaseRepository, bundleFile: any): Promise<any>
} }
export interface IResourceInterface { //This is the "raw" Fhir resource
export interface IResourceRaw {
resourceType: string resourceType: string
id?: string id?: string
meta?: IResourceMetaRaw
}
// This is the "raw" Fhir Bundle resource
export interface IResourceBundleRaw {
resourceType: string
id?: string
entry: IResourceBundleEntryRaw[]
total?: number
link?: IResourceBundleLinkRaw[]
meta?: IResourceMetaRaw
}
export interface IResourceBundleLinkRaw {
id?: string
relation: string
url: string
}
export interface IResourceBundleEntryRaw {
id?: string
fullUrl?: string
resource: IResourceRaw
}
export interface IResourceMetaRaw {
id?: string
versionId?: string
lastUpdated: string
} }

View File

@ -1,3 +1,4 @@
export enum DocType { export enum DocType {
Source= "source", Source = "source",
ResourceFhir = "resource_fhir"
} }

View File

@ -1,7 +1,11 @@
import {Source} from '../models/database/source'; import {Source} from '../models/database/source';
import {ResourceFhir} from '../models/database/resource_fhir';
// import {SourceSummary} from '../../app/models/fasten/source-summary'; // import {SourceSummary} from '../../app/models/fasten/source-summary';
export interface IDatabaseDocument { export interface IDatabaseDocument {
_id?: string
_rev?: string
doc_type: string
populateId(): void populateId(): void
} }
@ -31,4 +35,10 @@ export interface IDatabaseRepository {
DeleteSource(source_id: string): Promise<boolean> DeleteSource(source_id: string): Promise<boolean>
// GetSourceSummary(source_id: string): Promise<SourceSummary> // GetSourceSummary(source_id: string): Promise<SourceSummary>
GetSources(): Promise<IDatabasePaginatedResponse> GetSources(): Promise<IDatabasePaginatedResponse>
CreateResource(resource: ResourceFhir): Promise<string>
CreateResources(resources: ResourceFhir[]): Promise<string[]>
GetResource(resource_id: string): Promise<ResourceFhir>
GetResources(): Promise<IDatabasePaginatedResponse>
} }

View File

@ -43,7 +43,7 @@ describe('PouchdbRepository', () => {
const createdSource = await repository.GetSource(createdId) const createdSource = await repository.GetSource(createdId)
expect(createdSource.docType).toEqual(DocType.Source); expect(createdSource.doc_type).toEqual(DocType.Source);
expect(createdSource.patient).toEqual('patient'); expect(createdSource.patient).toEqual('patient');
expect(createdSource.source_type).toEqual(SourceType.Aetna); expect(createdSource.source_type).toEqual(SourceType.Aetna);
expect(createdSource.access_token).toEqual('hello-world'); expect(createdSource.access_token).toEqual('hello-world');

View File

@ -3,6 +3,7 @@ import {IDatabasePaginatedResponse, IDatabaseDocument, IDatabaseRepository} from
import * as PouchDB from 'pouchdb/dist/pouchdb'; import * as PouchDB from 'pouchdb/dist/pouchdb';
// import * as PouchDB from 'pouchdb'; // import * as PouchDB from 'pouchdb';
import {DocType} from './constants'; import {DocType} from './constants';
import {ResourceFhir} from '../models/database/resource_fhir';
export function NewRepositiory(databaseName: string = 'fasten'): IDatabaseRepository { export function NewRepositiory(databaseName: string = 'fasten'): IDatabaseRepository {
return new PouchdbRepository(databaseName) return new PouchdbRepository(databaseName)
@ -44,6 +45,32 @@ export class PouchdbRepository implements IDatabaseRepository {
return this.deleteDocument(source_id) return this.deleteDocument(source_id)
} }
public async CreateResource(resource: ResourceFhir): Promise<string> {
return this.createDocument(resource);
}
public async CreateResources(resources: ResourceFhir[]): Promise<string[]> {
return this.createBulk(resources);
}
public async GetResource(resource_id: string): Promise<ResourceFhir> {
return this.getDocument(resource_id)
.then((doc) => {
return new ResourceFhir(doc)
})
}
public async GetResources(): Promise<IDatabasePaginatedResponse> {
return this.findDocumentByDocType(DocType.ResourceFhir)
.then((docWrapper) => {
docWrapper.rows = docWrapper.rows.map((result) => {
return new ResourceFhir(result.doc)
})
return docWrapper
})
}
/////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////
// CRUD Operators // CRUD Operators
@ -73,6 +100,15 @@ export class PouchdbRepository implements IDatabaseRepository {
); );
} }
// create multiple documents, returns a list of generated ids
private createBulk(docs: IDatabaseDocument[]): Promise<string[]> {
return this.GetDB()
.bulkDocs(docs.map((doc) => { doc.populateId(); return doc }))
.then((results): string[] => {
return results.map((result) => result.id)
})
}
private getDocument(id: string): Promise<any> { private getDocument(id: string): Promise<any> {
return this.GetDB() return this.GetDB()
.get(id) .get(id)

View File

@ -0,0 +1,32 @@
import {DocType} from '../../database/constants';
import {IResourceRaw} from '../../conduit/interface';
import {Base64} from '../../utils/base64';
export class ResourceFhir {
_id?: string
_rev?: string
doc_type: string
created_at?: Date
updated_at?: Date
source_id: string
source_resource_type: string
source_resource_id: string
resource_raw: IResourceRaw
constructor(object?: any) {
if(object){
object.doc_type = DocType.ResourceFhir
return Object.assign(this, object)
} else{
this.doc_type = DocType.ResourceFhir
return this
}
}
populateId(){
//TODO: source_id should be base64 encoded (otherwise we get nested : chars)
this._id = `${this.doc_type}:${Base64.Encode(this.source_id)}:${this.source_resource_type}:${this.source_resource_id}`
}
}

View File

@ -5,7 +5,7 @@ import {DocType} from '../../../lib/database/constants';
export class Source extends LighthouseSourceMetadata{ export class Source extends LighthouseSourceMetadata{
_id?: string _id?: string
_rev?: string _rev?: string
docType: string doc_type: string
source_type: SourceType source_type: SourceType
patient: string patient: string
@ -16,11 +16,11 @@ export class Source extends LighthouseSourceMetadata{
constructor(object: any) { constructor(object: any) {
super() super()
object.docType = DocType.Source object.doc_type = DocType.Source
return Object.assign(this, object) return Object.assign(this, object)
} }
populateId(){ populateId(){
this._id = `source:${this.source_type}:${this.patient}` this._id = `${this.doc_type}:${this.source_type}:${this.patient}`
} }
} }

View File

@ -0,0 +1,8 @@
export class Base64 {
public static Encode(data: string): string {
return btoa(data)
}
public static Decode(data: string): string {
return atob(data)
}
}

View File

@ -1,6 +1,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"resolveJsonModule": true,
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": [
"jasmine", "jasmine",