moving models down to lib dir.

adding base client to conduit lib.
adding tests
renaming all Pouchdb record references to "Document".
This commit is contained in:
Jason Kulatunga 2022-10-05 06:38:00 -07:00
parent 5a61ed15c6
commit 0a5d71691f
15 changed files with 196 additions and 78 deletions

View File

@ -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<string> {
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<string> {
// 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<any> {
return this.getDB()
.get(id)
}
}

View File

@ -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({

View File

View File

@ -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"
});
});
})
})

View File

@ -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<IResourceInterface> {
//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<Source> {
//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
})
}
}

View File

@ -0,0 +1,14 @@
import {IDatabaseRepository} from '../database/interface';
export interface IClient {
GetRequest(resourceSubpath: string): Promise<any>
SyncAll(db: IDatabaseRepository): Promise<any>
//Manual client ONLY functions
SyncAllBundle(db: IDatabaseRepository, bundleFile: any): Promise<any>
}
export interface IResourceInterface {
resourceType: string
id?: string
}

View File

@ -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
}

View File

@ -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', () => {

View File

@ -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<string> {
return this.createRecord(source);
return this.createDocument(source);
}
public async GetSource(source_id: string): Promise<Source> {
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<IDatabasePaginatedResponse> {
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<boolean> {
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<string> {
// 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<string> {
// 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<any> {
private getDocument(id: string): Promise<any> {
return this.GetDB()
.get(id)
}
private findRecordByDocType(docType: DocType, includeDocs: boolean = true): Promise<IDatabasePaginatedResponse> {
private findDocumentByDocType(docType: DocType, includeDocs: boolean = true): Promise<IDatabasePaginatedResponse> {
return this.GetDB()
.allDocs({
include_docs: includeDocs,
@ -87,10 +87,10 @@ class PouchdbRepository implements IDatabaseRepository {
})
}
private async deleteRecord(id: string): Promise<boolean> {
const recordToDelete = await this.getRecord(id)
private async deleteDocument(id: string): Promise<boolean> {
const docToDelete = await this.getDocument(id)
return this.GetDB()
.remove(recordToDelete)
.remove(docToDelete)
.then((result) => {
return result.ok
})

View File

@ -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()