diff --git a/frontend/src/app/services/fasten-db.service.ts b/frontend/src/app/services/fasten-db.service.ts index b4944410..e9d89914 100644 --- a/frontend/src/app/services/fasten-db.service.ts +++ b/frontend/src/app/services/fasten-db.service.ts @@ -1,54 +1,10 @@ import { Injectable } from '@angular/core'; -import {Source} from '../models/database/source'; -// import * as PouchDB from 'pouchdb'; -import * as PouchDB from 'pouchdb/dist/pouchdb'; +import {PouchdbRepository} from '../../lib/database/pouchdb_repository'; @Injectable({ providedIn: 'root' }) -export class FastenDbService { - - localPouchDb: PouchDB.Database +export class FastenDbService extends PouchdbRepository { constructor() { - this.localPouchDb = new PouchDB('kittens'); + super("fasten"); } - - - async createSource(source: Source): Promise { - return this.createRecord(source); - } - - /////////////////////////////////////////////////////////////////////////////////////// - // CRUD Operators - /////////////////////////////////////////////////////////////////////////////////////// - - // Get the active PouchDB instance. Throws an error if no PouchDB instance is - // available (ie, user has not yet been configured with call to .configureForUser()). - public getDB(): any { - if(!this.localPouchDb) { - throw( new Error( "Database is not available - please configure an instance." ) ); - } - return this.localPouchDb; - } - - // create a new record. Returns a promise of the generated id. - private createRecord(record: Source) : Promise { - // make sure we always "populate" the ID for every record before submitting - record.populateId() - - // NOTE: All friends are given the key-prefix of "friend:". This way, when we go - // to query for friends, we can limit the scope to keys with in this key-space. - return this.getDB() - .put(record) - .then(( result ): string => { - return( result.id ); - } - ); - } - - //public only for testing - public getRecord(id: string): Promise { - return this.getDB() - .get(id) - } - } diff --git a/frontend/src/app/services/lighthouse.service.ts b/frontend/src/app/services/lighthouse.service.ts index 0ebe3f50..91ec696b 100644 --- a/frontend/src/app/services/lighthouse.service.ts +++ b/frontend/src/app/services/lighthouse.service.ts @@ -4,7 +4,7 @@ import {Observable} from 'rxjs'; import {environment} from '../../environments/environment'; import {map, tap} from 'rxjs/operators'; import {ResponseWrapper} from '../models/response-wrapper'; -import {LighthouseSourceMetadata} from '../models/lighthouse/lighthouse-source-metadata'; +import {LighthouseSourceMetadata} from '../../lib/models/lighthouse/lighthouse-source-metadata'; import * as Oauth from '@panva/oauth4webapi'; @Injectable({ diff --git a/frontend/src/lib/conduit/factory.ts b/frontend/src/lib/conduit/factory.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/lib/conduit/fhir/base/base_client.spec.ts b/frontend/src/lib/conduit/fhir/base/base_client.spec.ts new file mode 100644 index 00000000..f63ba260 --- /dev/null +++ b/frontend/src/lib/conduit/fhir/base/base_client.spec.ts @@ -0,0 +1,54 @@ + +import {BaseClient} from './base_client'; +import {Source} from '../../../models/database/source'; + +class TestClient extends BaseClient { + constructor(source: Source) { + super(source); + } +} + +describe('BaseClient', () => { + let client: TestClient; + + beforeEach(async () => { + client = new TestClient(new Source({ + "authorization_endpoint": "https://auth.logicahealth.org/authorize", + "token_endpoint": "https://auth.logicahealth.org/token", + "introspection_endpoint": "https://auth.logicahealth.org/introspect", + "userinfo_endpoint": "", + "scopes_supported": ["openid", "fhirUser", "patient/*.read", "offline_access"], + "issuer": "https://auth.logicahealth.org", + "grant_types_supported": ["authorization_code"], + "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, + "source_type": "logica", + "patient": "smart-1288992", + "access_token": "xxx.xxx.xxx", + "refresh_token": "xxx.xxx.", + "expires_at": 1664949030, + })); + }); + + it('should be created', () => { + expect(client).toBeTruthy(); + }); + + describe('GetRequest', () => { + it('should make an authorized request', async () => { + const resp = await client.GetRequest("Patient/smart-1288992") + + expect(resp).toEqual({ + resourceType: "Patient", + id: "source:aetna:patient" + }); + }); + }) + + +}) diff --git a/frontend/src/lib/conduit/fhir/base/base_client.ts b/frontend/src/lib/conduit/fhir/base/base_client.ts new file mode 100644 index 00000000..10916bae --- /dev/null +++ b/frontend/src/lib/conduit/fhir/base/base_client.ts @@ -0,0 +1,94 @@ +import {Source} from '../../../models/database/source'; +import * as Oauth from '@panva/oauth4webapi'; +import {IResourceInterface} from '../../interface'; + +// BaseClient is an abstract/partial class, its intended to be used by FHIR clients, and generically handle OAuth requests. +export abstract class BaseClient { + + private oauthClient: Oauth.Client + private oauthAuthorizationServer: Oauth.AuthorizationServer + public source: Source + public headers: Headers = new Headers() + + protected constructor(source: Source) { + this.source = source + + //init Oauth client based on source configuration + this.oauthClient = { + client_id: source.client_id, + client_secret: "placeholder" //this is always a placeholder, if client_secret is required (for confidential clients), token_endpoint will be Lighthouse server. + } + this.oauthAuthorizationServer = { + issuer: source.issuer, + authorization_endpoint: source.authorization_endpoint, + token_endpoint: source.token_endpoint, + introspection_endpoint: source.introspection_endpoint, + } + } + + + public async GetRequest(resourceSubpathOrNext: string): Promise { + + //check if the url is absolute + let resourceUrl: string + if (!(resourceSubpathOrNext.indexOf('http://') === 0 || resourceSubpathOrNext.indexOf('https://') === 0)) { + //not absolute, so lets prefix with the source api endpoint + resourceUrl = `${this.source.api_endpoint_base_url.trimEnd()}/${resourceSubpathOrNext.trimStart()}` + } else { + resourceUrl = resourceSubpathOrNext + } + + //refresh the source if required + this.source = await this.refreshExpiredTokenIfRequired(this.source) + + //make a request to the protected resource + const resp = await Oauth.protectedResourceRequest(this.source.access_token, 'GET', new URL(resourceUrl), this.headers, null) + + if(resp.status >=300 || resp.status < 200){ + // b, _ := io.ReadAll(resp.Body) + throw new Error(`An error occurred during request ${resourceUrl} - ${resp.status} - ${resp.statusText} ${await resp.text()}`) + } + return resp.json() + // err = ParseBundle(resp.Body, decodeModelPtr) + // return err + } + + private async refreshExpiredTokenIfRequired(source: Source): Promise { + //check if token has expired, and a refreshtoken is available + // Note: source.expires_at is in seconds, Date.now() is in milliseconds. + if(source.expires_at > Math.floor(Date.now() / 1000)) { //not expired return + return Promise.resolve(source) + } + if(!source.refresh_token){ + return Promise.reject(new Error("access token is expired, but no refresh token available")) + } + + console.log("access token expired, refreshing...") + return Oauth.refreshTokenGrantRequest(this.oauthAuthorizationServer, this.oauthClient, source.refresh_token) + .then((refreshTokenResp) => { + return Oauth.processRefreshTokenResponse(this.oauthAuthorizationServer, this.oauthClient, refreshTokenResp) + }) + .then((newToken) => { + if(newToken.access_token != source.access_token){ + // { + // access_token: 'token', + // token_type: 'bearer', + // expires_in: 60, + // scope: 'api:read', + // refresh_token: 'refresh_token', + // } + + source.access_token = newToken.access_token as string + // @ts-ignore + source.expires_at = Math.floor(Date.now() / 1000) + parseInt(newToken.expires_in); + + // update the "source" credential with new data (which will need to be sent + // Don't overwrite `RefreshToken` with an empty value + if(newToken.refresh_token != ""){ + source.refresh_token = newToken.refresh_token as string + } + } + return source + }) + } +} diff --git a/frontend/src/lib/conduit/interface.ts b/frontend/src/lib/conduit/interface.ts new file mode 100644 index 00000000..503fa9c4 --- /dev/null +++ b/frontend/src/lib/conduit/interface.ts @@ -0,0 +1,14 @@ +import {IDatabaseRepository} from '../database/interface'; + +export interface IClient { + GetRequest(resourceSubpath: string): Promise + SyncAll(db: IDatabaseRepository): Promise + + //Manual client ONLY functions + SyncAllBundle(db: IDatabaseRepository, bundleFile: any): Promise +} + +export interface IResourceInterface { + resourceType: string + id?: string +} diff --git a/frontend/src/lib/database/interface.ts b/frontend/src/lib/database/interface.ts index e6696e6b..001e0212 100644 --- a/frontend/src/lib/database/interface.ts +++ b/frontend/src/lib/database/interface.ts @@ -1,7 +1,7 @@ -import {Source} from '../../app/models/database/source'; +import {Source} from '../models/database/source'; // import {SourceSummary} from '../../app/models/fasten/source-summary'; -export interface IDatabaseRecord { +export interface IDatabaseDocument { populateId(): void } diff --git a/frontend/src/lib/database/pouchdb_repository.spec.ts b/frontend/src/lib/database/pouchdb_repository.spec.ts index 26b7e075..b24f9390 100644 --- a/frontend/src/lib/database/pouchdb_repository.spec.ts +++ b/frontend/src/lib/database/pouchdb_repository.spec.ts @@ -1,7 +1,7 @@ import {IDatabaseRepository} from './interface'; import {NewRepositiory} from './pouchdb_repository'; -import {SourceType} from '../../app/models/database/types'; -import {Source} from '../../app/models/database/source'; +import {SourceType} from '../models/database/types'; +import {Source} from '../models/database/source'; import {DocType} from './constants'; describe('PouchdbRepository', () => { diff --git a/frontend/src/lib/database/pouchdb_repository.ts b/frontend/src/lib/database/pouchdb_repository.ts index 424ec727..78e0a34b 100644 --- a/frontend/src/lib/database/pouchdb_repository.ts +++ b/frontend/src/lib/database/pouchdb_repository.ts @@ -1,5 +1,5 @@ -import {Source} from '../../app/models/database/source'; -import {IDatabasePaginatedResponse, IDatabaseRecord, IDatabaseRepository} from './interface'; +import {Source} from '../models/database/source'; +import {IDatabasePaginatedResponse, IDatabaseDocument, IDatabaseRepository} from './interface'; import * as PouchDB from 'pouchdb/dist/pouchdb'; // import * as PouchDB from 'pouchdb'; import {DocType} from './constants'; @@ -8,7 +8,7 @@ export function NewRepositiory(databaseName: string = 'fasten'): IDatabaseReposi return new PouchdbRepository(databaseName) } -class PouchdbRepository implements IDatabaseRepository { +export class PouchdbRepository implements IDatabaseRepository { localPouchDb: PouchDB.Database constructor(public databaseName: string) { @@ -19,29 +19,29 @@ class PouchdbRepository implements IDatabaseRepository { } public async CreateSource(source: Source): Promise { - return this.createRecord(source); + return this.createDocument(source); } public async GetSource(source_id: string): Promise { - return this.getRecord(source_id) - .then((record) => { - return new Source(record) + return this.getDocument(source_id) + .then((doc) => { + return new Source(doc) }) } public async GetSources(): Promise { - return this.findRecordByDocType(DocType.Source) - .then((recordsWrapper) => { + return this.findDocumentByDocType(DocType.Source) + .then((docWrapper) => { - recordsWrapper.rows = recordsWrapper.rows.map((record) => { - return new Source(record.doc) + docWrapper.rows = docWrapper.rows.map((result) => { + return new Source(result.doc) }) - return recordsWrapper + return docWrapper }) } public async DeleteSource(source_id: string): Promise { - return this.deleteRecord(source_id) + return this.deleteDocument(source_id) } @@ -58,27 +58,27 @@ class PouchdbRepository implements IDatabaseRepository { return this.localPouchDb; } - // create a new record. Returns a promise of the generated id. - private createRecord(record: IDatabaseRecord) : Promise { - // make sure we always "populate" the ID for every record before submitting - record.populateId() + // create a new document. Returns a promise of the generated id. + private createDocument(doc: IDatabaseDocument) : Promise { + // make sure we always "populate" the ID for every document before submitting + doc.populateId() // NOTE: All friends are given the key-prefix of "friend:". This way, when we go // to query for friends, we can limit the scope to keys with in this key-space. return this.GetDB() - .put(record) + .put(doc) .then(( result ): string => { return( result.id ); } ); } - private getRecord(id: string): Promise { + private getDocument(id: string): Promise { return this.GetDB() .get(id) } - private findRecordByDocType(docType: DocType, includeDocs: boolean = true): Promise { + private findDocumentByDocType(docType: DocType, includeDocs: boolean = true): Promise { return this.GetDB() .allDocs({ include_docs: includeDocs, @@ -87,10 +87,10 @@ class PouchdbRepository implements IDatabaseRepository { }) } - private async deleteRecord(id: string): Promise { - const recordToDelete = await this.getRecord(id) + private async deleteDocument(id: string): Promise { + const docToDelete = await this.getDocument(id) return this.GetDB() - .remove(recordToDelete) + .remove(docToDelete) .then((result) => { return result.ok }) diff --git a/frontend/src/app/models/database/base.ts b/frontend/src/lib/models/database/base.ts similarity index 100% rename from frontend/src/app/models/database/base.ts rename to frontend/src/lib/models/database/base.ts diff --git a/frontend/src/app/models/database/source.ts b/frontend/src/lib/models/database/source.ts similarity index 82% rename from frontend/src/app/models/database/source.ts rename to frontend/src/lib/models/database/source.ts index b911cf55..b5a54dfb 100644 --- a/frontend/src/app/models/database/source.ts +++ b/frontend/src/lib/models/database/source.ts @@ -10,9 +10,9 @@ export class Source extends LighthouseSourceMetadata{ patient: string access_token: string - refresh_token: string - id_token: string - expires_at: number + refresh_token?: string + id_token?: string + expires_at: number //seconds since epoch constructor(object: any) { super() diff --git a/frontend/src/app/models/database/types.ts b/frontend/src/lib/models/database/types.ts similarity index 100% rename from frontend/src/app/models/database/types.ts rename to frontend/src/lib/models/database/types.ts diff --git a/frontend/src/app/models/lighthouse/authorize-claim.spec.ts b/frontend/src/lib/models/lighthouse/authorize-claim.spec.ts similarity index 100% rename from frontend/src/app/models/lighthouse/authorize-claim.spec.ts rename to frontend/src/lib/models/lighthouse/authorize-claim.spec.ts diff --git a/frontend/src/app/models/lighthouse/authorize-claim.ts b/frontend/src/lib/models/lighthouse/authorize-claim.ts similarity index 100% rename from frontend/src/app/models/lighthouse/authorize-claim.ts rename to frontend/src/lib/models/lighthouse/authorize-claim.ts diff --git a/frontend/src/app/models/lighthouse/lighthouse-source-metadata.ts b/frontend/src/lib/models/lighthouse/lighthouse-source-metadata.ts similarity index 100% rename from frontend/src/app/models/lighthouse/lighthouse-source-metadata.ts rename to frontend/src/lib/models/lighthouse/lighthouse-source-metadata.ts