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:
parent
5a61ed15c6
commit
0a5d71691f
|
@ -1,54 +1,10 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {Source} from '../models/database/source';
|
import {PouchdbRepository} from '../../lib/database/pouchdb_repository';
|
||||||
// import * as PouchDB from 'pouchdb';
|
|
||||||
import * as PouchDB from 'pouchdb/dist/pouchdb';
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class FastenDbService {
|
export class FastenDbService extends PouchdbRepository {
|
||||||
|
|
||||||
localPouchDb: PouchDB.Database
|
|
||||||
constructor() {
|
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import {Observable} from 'rxjs';
|
||||||
import {environment} from '../../environments/environment';
|
import {environment} from '../../environments/environment';
|
||||||
import {map, tap} from 'rxjs/operators';
|
import {map, tap} from 'rxjs/operators';
|
||||||
import {ResponseWrapper} from '../models/response-wrapper';
|
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';
|
import * as Oauth from '@panva/oauth4webapi';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
|
|
@ -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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
})
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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';
|
// import {SourceSummary} from '../../app/models/fasten/source-summary';
|
||||||
|
|
||||||
export interface IDatabaseRecord {
|
export interface IDatabaseDocument {
|
||||||
populateId(): void
|
populateId(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {IDatabaseRepository} from './interface';
|
import {IDatabaseRepository} from './interface';
|
||||||
import {NewRepositiory} from './pouchdb_repository';
|
import {NewRepositiory} from './pouchdb_repository';
|
||||||
import {SourceType} from '../../app/models/database/types';
|
import {SourceType} from '../models/database/types';
|
||||||
import {Source} from '../../app/models/database/source';
|
import {Source} from '../models/database/source';
|
||||||
import {DocType} from './constants';
|
import {DocType} from './constants';
|
||||||
|
|
||||||
describe('PouchdbRepository', () => {
|
describe('PouchdbRepository', () => {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {Source} from '../../app/models/database/source';
|
import {Source} from '../models/database/source';
|
||||||
import {IDatabasePaginatedResponse, IDatabaseRecord, IDatabaseRepository} from './interface';
|
import {IDatabasePaginatedResponse, IDatabaseDocument, IDatabaseRepository} from './interface';
|
||||||
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';
|
||||||
|
@ -8,7 +8,7 @@ export function NewRepositiory(databaseName: string = 'fasten'): IDatabaseReposi
|
||||||
return new PouchdbRepository(databaseName)
|
return new PouchdbRepository(databaseName)
|
||||||
}
|
}
|
||||||
|
|
||||||
class PouchdbRepository implements IDatabaseRepository {
|
export class PouchdbRepository implements IDatabaseRepository {
|
||||||
|
|
||||||
localPouchDb: PouchDB.Database
|
localPouchDb: PouchDB.Database
|
||||||
constructor(public databaseName: string) {
|
constructor(public databaseName: string) {
|
||||||
|
@ -19,29 +19,29 @@ class PouchdbRepository implements IDatabaseRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async CreateSource(source: Source): Promise<string> {
|
public async CreateSource(source: Source): Promise<string> {
|
||||||
return this.createRecord(source);
|
return this.createDocument(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async GetSource(source_id: string): Promise<Source> {
|
public async GetSource(source_id: string): Promise<Source> {
|
||||||
return this.getRecord(source_id)
|
return this.getDocument(source_id)
|
||||||
.then((record) => {
|
.then((doc) => {
|
||||||
return new Source(record)
|
return new Source(doc)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public async GetSources(): Promise<IDatabasePaginatedResponse> {
|
public async GetSources(): Promise<IDatabasePaginatedResponse> {
|
||||||
return this.findRecordByDocType(DocType.Source)
|
return this.findDocumentByDocType(DocType.Source)
|
||||||
.then((recordsWrapper) => {
|
.then((docWrapper) => {
|
||||||
|
|
||||||
recordsWrapper.rows = recordsWrapper.rows.map((record) => {
|
docWrapper.rows = docWrapper.rows.map((result) => {
|
||||||
return new Source(record.doc)
|
return new Source(result.doc)
|
||||||
})
|
})
|
||||||
return recordsWrapper
|
return docWrapper
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public async DeleteSource(source_id: string): Promise<boolean> {
|
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;
|
return this.localPouchDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// create a new record. Returns a promise of the generated id.
|
// create a new document. Returns a promise of the generated id.
|
||||||
private createRecord(record: IDatabaseRecord) : Promise<string> {
|
private createDocument(doc: IDatabaseDocument) : Promise<string> {
|
||||||
// make sure we always "populate" the ID for every record before submitting
|
// make sure we always "populate" the ID for every document before submitting
|
||||||
record.populateId()
|
doc.populateId()
|
||||||
|
|
||||||
// NOTE: All friends are given the key-prefix of "friend:". This way, when we go
|
// 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.
|
// to query for friends, we can limit the scope to keys with in this key-space.
|
||||||
return this.GetDB()
|
return this.GetDB()
|
||||||
.put(record)
|
.put(doc)
|
||||||
.then(( result ): string => {
|
.then(( result ): string => {
|
||||||
return( result.id );
|
return( result.id );
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRecord(id: string): Promise<any> {
|
private getDocument(id: string): Promise<any> {
|
||||||
return this.GetDB()
|
return this.GetDB()
|
||||||
.get(id)
|
.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private findRecordByDocType(docType: DocType, includeDocs: boolean = true): Promise<IDatabasePaginatedResponse> {
|
private findDocumentByDocType(docType: DocType, includeDocs: boolean = true): Promise<IDatabasePaginatedResponse> {
|
||||||
return this.GetDB()
|
return this.GetDB()
|
||||||
.allDocs({
|
.allDocs({
|
||||||
include_docs: includeDocs,
|
include_docs: includeDocs,
|
||||||
|
@ -87,10 +87,10 @@ class PouchdbRepository implements IDatabaseRepository {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteRecord(id: string): Promise<boolean> {
|
private async deleteDocument(id: string): Promise<boolean> {
|
||||||
const recordToDelete = await this.getRecord(id)
|
const docToDelete = await this.getDocument(id)
|
||||||
return this.GetDB()
|
return this.GetDB()
|
||||||
.remove(recordToDelete)
|
.remove(docToDelete)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
return result.ok
|
return result.ok
|
||||||
})
|
})
|
||||||
|
|
|
@ -10,9 +10,9 @@ export class Source extends LighthouseSourceMetadata{
|
||||||
|
|
||||||
patient: string
|
patient: string
|
||||||
access_token: string
|
access_token: string
|
||||||
refresh_token: string
|
refresh_token?: string
|
||||||
id_token: string
|
id_token?: string
|
||||||
expires_at: number
|
expires_at: number //seconds since epoch
|
||||||
|
|
||||||
constructor(object: any) {
|
constructor(object: any) {
|
||||||
super()
|
super()
|
Loading…
Reference in New Issue