diff --git a/api/docker-compose.yml b/api/docker-compose.yml index dee08d3392e474fc1124072cc287d6c2f342d72d..5a4acef400c6e73f17bde060650479a23a6a4318 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -1,11 +1,11 @@ services: db: - image: postgres + image: postgres:14-alpine restart: always ports: - "5454:5432" environment: - POSTGRES_PASSWORD: pass123 + POSTGRES_PASSWORD: pass123 volumes: - db-volume:/var/lib/postgres networks: diff --git a/api/src/auth/auth.resolver.spec.ts b/api/src/auth/auth.resolver.spec.ts index ca6c620bddc48e94292a126f95d39452caedc3a6..c109dbe8712230015317033214cd8f2870350c7e 100644 --- a/api/src/auth/auth.resolver.spec.ts +++ b/api/src/auth/auth.resolver.spec.ts @@ -2,11 +2,14 @@ import { getMockRes } from '@jest-mock/express'; import { Test, TestingModule } from '@nestjs/testing'; import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'; import LocalService from '../engine/connectors/local/main.connector'; -import { ENGINE_SERVICE } from '../engine/engine.constants'; +import { + ENGINE_MODULE_OPTIONS, + ENGINE_SERVICE, +} from '../engine/engine.constants'; +import { User } from '../users/models/user.model'; import { authConstants } from './auth-constants'; import { AuthResolver } from './auth.resolver'; import { AuthService } from './auth.service'; -import { User } from '../users/models/user.model'; const moduleMocker = new ModuleMocker(global); @@ -40,6 +43,12 @@ describe('AuthResolver', () => { provide: ENGINE_SERVICE, useClass: LocalService, }, + { + provide: ENGINE_MODULE_OPTIONS, + useValue: { + type: 'DummyConnector', + }, + }, AuthResolver, ], }) @@ -71,7 +80,8 @@ describe('AuthResolver', () => { }); it('logout', () => { - resolver.logout(res, user); + const request: any = jest.fn(); + resolver.logout(request, res, user); expect(mockClearCookie.mock.calls[0][0]).toBe(authConstants.cookie.name); }); diff --git a/api/src/auth/auth.resolver.ts b/api/src/auth/auth.resolver.ts index 228943e65bc8582c5a209e5d815523f1716b98ad..cfa8e714c43004d503eaf016e2f00a11503990d7 100644 --- a/api/src/auth/auth.resolver.ts +++ b/api/src/auth/auth.resolver.ts @@ -6,11 +6,15 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; -import { Response } from 'express'; +import { Response, Request } from 'express'; +import { GQLRequest } from '../common/decorators/gql-request.decoractor'; import { GQLResponse } from '../common/decorators/gql-response.decoractor'; import { parseToBoolean } from '../common/utilities'; -import { ENGINE_SERVICE } from '../engine/engine.constants'; -import { IEngineService } from '../engine/engine.interfaces'; +import { + ENGINE_MODULE_OPTIONS, + ENGINE_SERVICE, +} from '../engine/engine.constants'; +import { IEngineOptions, IEngineService } from '../engine/engine.interfaces'; import { User } from '../users/models/user.model'; import { authConstants } from './auth-constants'; import { AuthService } from './auth.service'; @@ -29,6 +33,8 @@ export class AuthResolver { constructor( @Inject(ENGINE_SERVICE) private readonly engineService: IEngineService, + @Inject(ENGINE_MODULE_OPTIONS) + private readonly engineOptions: IEngineOptions, private readonly authService: AuthService, private readonly configService: ConfigService, ) {} @@ -68,11 +74,23 @@ export class AuthResolver { @Mutation(() => Boolean) @UseGuards(JwtAuthGuard) - logout(@GQLResponse() res: Response, @CurrentUser() user: User): boolean { - if (user) this.logger.verbose(`${user.username} logged out`); + logout( + @GQLRequest() req: Request, + @GQLResponse() res: Response, + @CurrentUser() user: User, + ): boolean { + if (user) { + this.logger.verbose(`${user.username} logged out`); + try { + this.engineService.logout?.(req); + } catch (e) { + this.logger.debug( + `Service ${this.engineOptions.type} produce an error when logging out ${user.username}`, + ); + } + } res.clearCookie(authConstants.cookie.name); - this.engineService.logout?.(); return true; } diff --git a/api/src/config/matomo.config.ts b/api/src/config/matomo.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e7fa269a4913509846da9d367fc607954c7ee52 --- /dev/null +++ b/api/src/config/matomo.config.ts @@ -0,0 +1,10 @@ +import { registerAs } from '@nestjs/config'; +import { parseToBoolean } from 'src/common/utilities'; + +export default registerAs('matomo', () => { + return { + enabled: parseToBoolean(process.env.MATOMO_ENABLED, false), + urlBase: process.env.MATOMO_URL || undefined, + siteId: process.env.MATOMO_SITE_ID || undefined, + }; +}); diff --git a/api/src/engine/connectors/datashield/main.connector.ts b/api/src/engine/connectors/datashield/main.connector.ts index 454654e8e774617de4ef68b5e99f9e15a8b1918c..ad0e132cda9a53cb5075f101aef35ab04263802d 100644 --- a/api/src/engine/connectors/datashield/main.connector.ts +++ b/api/src/engine/connectors/datashield/main.connector.ts @@ -27,7 +27,7 @@ import { ListExperiments } from 'src/engine/models/experiment/list-experiments.m import { RawResult } from 'src/engine/models/result/raw-result.model'; import { TableResult, - ThemeType, + TableStyle, } from 'src/engine/models/result/table-result.model'; import { User } from 'src/users/models/user.model'; import { @@ -141,7 +141,7 @@ export default class DataShieldService implements IEngineService { const table = transformToTable.evaluate(data); return { ...table, - theme: ThemeType.NORMAL, + tableStyle: TableStyle.NORMAL, }; } @@ -205,6 +205,21 @@ export default class DataShieldService implements IEngineService { throw new NotImplementedException(); } + async logout(request: Request): Promise<void> { + const user = request.user as User; + const cookie = [`sid=${user.extraFields['sid']}`, `user=${user.id}`].join( + ';', + ); + + const path = new URL('/logout', this.options.baseurl).href; + + this.httpService.get(path, { + headers: { + cookie, + }, + }); + } + async editExperient( id: string, expriment: ExperimentEditInput, diff --git a/api/src/engine/connectors/exareme/converters.ts b/api/src/engine/connectors/exareme/converters.ts index 5ca4b79db61a06924af99aebb4e0ca44670de630..56f35ddcd2876a5cd6435cd3454c6fb414034923 100644 --- a/api/src/engine/connectors/exareme/converters.ts +++ b/api/src/engine/connectors/exareme/converters.ts @@ -227,6 +227,14 @@ export const dataToExperiment = ( status: 'error', variables: [], domain: data['domain'] ?? '', + results: [ + { + rawdata: { + type: 'text/plain+error', + data: 'Error when parsing experiment data from the Engine', + }, + }, + ], datasets: [], algorithm: { id: 'unknown', diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.spec.ts b/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.spec.ts index 9aa0115d8daa56a9ab36233472c86aa1c48025b9..a7af56efcc46540e8565e6130e5d7c70e420ee8e 100644 --- a/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.spec.ts +++ b/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.spec.ts @@ -1,7 +1,7 @@ import { Experiment } from '../../../../models/experiment/experiment.model'; import { HeatMapResult } from '../../../../models/result/heat-map-result.model'; import handlers from '..'; -import { BarChartResult } from 'src/engine/models/result/bar-chart-result.model'; +import { BarChartResult } from '../../../../models/result/bar-chart-result.model'; const createExperiment = (): Experiment => ({ id: 'dummy-id', @@ -84,7 +84,10 @@ describe('PCA result handler', () => { exp.results.forEach((it) => { if (it['matrix']) { const heatmap = it as HeatMapResult; - expect(heatmap.matrix).toEqual(data.eigen_vecs); + const matrix = data.eigen_vecs[0].map( + (_, i) => data.eigen_vecs.map((row) => row[i]), // reverse matrix as we want row-major order + ); + expect(heatmap.matrix).toEqual(matrix); expect(heatmap.yAxis.categories).toEqual(exp.variables); } }); diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.ts b/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.ts index 3278962199e512fa04c3a2b7c55bd1339447b6c5..64ff970285259fd69147fe2ff8fc0b3ca9eab668 100644 --- a/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.ts +++ b/api/src/engine/connectors/exareme/handlers/algorithms/PCA.handler.ts @@ -42,7 +42,13 @@ export default class PCAHandler extends BaseHandler { }, }; - if (matrix) exp.results.push(heatMapChart); + if (matrix && matrix.length > 0) { + heatMapChart.matrix = matrix[0].map( + (_, i) => matrix.map((row) => row[i]), // reverse matrix as we want row-major order + ); + } + + if (heatMapChart.matrix) exp.results.push(heatMapChart); this.next?.handle(exp, data); } diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.spec.ts b/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4cd6dcf6a9f3cd8c1d837b8a46b4d310842b874 --- /dev/null +++ b/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.spec.ts @@ -0,0 +1,159 @@ +import handlers from '..'; +import { Experiment } from '../../../../models/experiment/experiment.model'; +import AnovaOneWayHandler from './anova-one-way.handler'; + +const createExperiment = (): Experiment => ({ + id: 'dummy-id', + name: 'Testing purpose', + algorithm: { + id: 'Anova_OnEway', + }, + datasets: ['desd-synthdata'], + domain: 'dementia', + variables: ['rightcerebralwhitematter'], + coVariables: ['ppmicategory'], + results: [], +}); + +describe('Anova oneway result handler', () => { + const anovaHandler = new AnovaOneWayHandler(); + const data = { + x_label: 'Variable X', + y_label: 'Variable Y', + df_residual: 1424.0, + df_explained: 3.0, + ss_residual: 1941.1517872154072, + ss_explained: 23.52938815624377, + ms_residual: 1.3631683898984601, + ms_explained: 7.843129385414589, + p_value: 0.0006542139533101455, + f_stat: 5.753602741623733, + tuckey_test: [ + { + groupA: 'GENPD', + groupB: 'HC', + meanA: 10.200898765432095, + meanB: 10.50253333333334, + diff: -0.3016345679012442, + se: 0.11017769051976001, + t_stat: -2.737710025308137, + p_tuckey: 0.03178790563153744, + }, + { + groupA: 'GENPD', + groupB: 'PD', + meanA: 10.200898765432095, + meanB: 10.530083456790125, + diff: -0.3291846913580301, + se: 0.10048653456497285, + t_stat: -3.2759084864767125, + p_tuckey: 0.005936908999390811, + }, + { + groupA: 'GENPD', + groupB: 'PRODROMA', + meanA: 10.200898765432095, + meanB: 10.161453333333334, + diff: 0.039445432098760946, + se: 0.1534957169892615, + t_stat: 0.2569806693793321, + p_tuckey: 0.9, + }, + { + groupA: 'HC', + groupB: 'PD', + meanA: 10.50253333333334, + meanB: 10.530083456790125, + diff: -0.02755012345678587, + se: 0.07353521425604895, + t_stat: -0.37465211375949203, + p_tuckey: 0.9, + }, + { + groupA: 'HC', + groupB: 'PRODROMA', + meanA: 10.50253333333334, + meanB: 10.161453333333334, + diff: 0.34108000000000516, + se: 0.13737110045731235, + t_stat: 2.4829094246500176, + p_tuckey: 0.0630887851749381, + }, + { + groupA: 'PD', + groupB: 'PRODROMA', + meanA: 10.530083456790125, + meanB: 10.161453333333334, + diff: 0.368630123456791, + se: 0.1297275582960786, + t_stat: 2.8415714309172655, + p_tuckey: 0.02355122851783331, + }, + ], + min_per_group: [ + { + GENPD: 7.2276, + HC: 7.2107, + PD: 7.0258, + PRODROMA: 6.3771, + }, + ], + max_per_group: [ + { + GENPD: 13.7312, + HC: 14.52, + PD: 14.4812, + PRODROMA: 12.3572, + }, + ], + ci_info: { + sample_stds: { + GENPD: 1.2338388511229372, + HC: 1.1276421260632183, + PD: 1.16245855322075, + PRODROMA: 1.197046185656396, + }, + means: { + GENPD: 10.200898765432095, + HC: 10.50253333333334, + PD: 10.530083456790125, + PRODROMA: 10.161453333333334, + }, + 'm-s': { + GENPD: 8.967059914309157, + HC: 9.374891207270121, + PD: 9.367624903569375, + PRODROMA: 8.964407147676939, + }, + 'm+s': { + GENPD: 11.434737616555033, + HC: 11.630175459396558, + PD: 11.692542010010875, + PRODROMA: 11.35849951898973, + }, + }, + }; + + it('Test anova 1 way handler', () => { + const exp = createExperiment(); + const table1 = anovaHandler.getSummaryTable(data, exp.coVariables[0]); + const table2 = anovaHandler.getTuckeyTable(data); + const meanPlot = anovaHandler.getMeanPlot(data); + + handlers(exp, data); + + expect(exp.results.length).toBeGreaterThanOrEqual(3); + expect(exp.results).toContainEqual(table1); + expect(exp.results).toContainEqual(table2); + expect(exp.results).toContainEqual(meanPlot); + + expect(table1.data[0].length).toEqual(6); + expect(table2.headers.length).toEqual(8); + expect(table2.data).toBeTruthy(); + + expect(meanPlot.pointCIs.length).toBeGreaterThan(1); + expect(meanPlot.name).toEqual( + `Mean Plot: ${data.y_label} ~ ${data.x_label}`, + ); + }); +}); diff --git a/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.ts b/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8d9a6750785d57df2f2637572d306cd313935ee --- /dev/null +++ b/api/src/engine/connectors/exareme/handlers/algorithms/anova-one-way.handler.ts @@ -0,0 +1,114 @@ +import * as jsonata from 'jsonata'; // old import style needed due to 'export = jsonata' +import { MeanChartResult } from 'src/engine/models/result/means-chart-result.model'; +import { Experiment } from '../../../../models/experiment/experiment.model'; +import { + TableResult, + TableStyle, +} from '../../../../models/result/table-result.model'; +import BaseHandler from '../base.handler'; + +export default class AnovaOneWayHandler extends BaseHandler { + private static readonly tuckeyTransform = jsonata(` + { + "name": 'Tuckey Honest Significant Differences', + "headers": [ + {"name": 'A', "type": 'string'}, + {"name": 'B', "type": 'string'}, + {"name": 'Mean A', "type": 'string'}, + {"name": 'Mean B', "type": 'string'}, + {"name": 'Diff', "type": 'string'}, + {"name": 'Standard error', "type": 'string'}, + {"name": 'T value', "type": 'string'}, + {"name": 'P value', "type": 'string'} + ], + "data": tuckey_test.[$.groupA, $.groupB, $.meanA, $.meanB, $.diff, $.se, $.t_stat, $.p_tuckey] + } + `); + + private static readonly meanPlotTransform = jsonata(` + ( + $cats:= $keys(ci_info.means); + { + "name": "Mean Plot: " & y_label & ' ~ ' & x_label, + "xAxis": { + "label": x_label, + "categories": $cats + }, + "yAxis": { + "label": '95% CI: ' & y_label + }, + "pointCIs": $cats.[{ + "min": $lookup($$.ci_info.'m-s', $), + "mean": $lookup($$.ci_info.means, $), + "max": $lookup($$.ci_info.'m+s', $) + }] + }) + `); + + canHandle(algorithm: string): boolean { + return algorithm.toLocaleLowerCase() === 'anova_oneway'; + } + + getTuckeyTable(data: unknown): TableResult | undefined { + const tableData = AnovaOneWayHandler.tuckeyTransform.evaluate(data); + + if (!tableData) return undefined; + + const tableResult: TableResult = { + ...tableData, + tableStyle: TableStyle.NORMAL, + } as unknown as TableResult; + + return tableResult; + } + + getSummaryTable(data: unknown, varname: string): TableResult | undefined { + const tableSummary: TableResult = { + name: 'Annova summary', + tableStyle: TableStyle.NORMAL, + headers: ['', 'DF', 'SS', 'MS', 'F ratio', 'P value'].map((name) => ({ + name, + type: 'string', + })), + data: [ + [ + varname, + data['df_explained'], + data['ss_explained'], + data['ms_explained'], + data['p_value'], + data['f_stat'], + ], + [ + 'Residual', + data['df_residual'], + data['ss_residual'], + data['ms_residual'], + '', + '', + ], + ], + }; + + return tableSummary; + } + + getMeanPlot(data: unknown): MeanChartResult { + return AnovaOneWayHandler.meanPlotTransform.evaluate(data); + } + + handle(exp: Experiment, data: unknown): void { + if (!this.canHandle(exp.algorithm.id)) return super.handle(exp, data); + + const summaryTable = this.getSummaryTable(data, exp.coVariables[0]); + if (summaryTable) exp.results.push(summaryTable); + + const tuckeyTable = this.getTuckeyTable(data); + if (tuckeyTable) exp.results.push(tuckeyTable); + + const meanPlot = this.getMeanPlot(data); + if (meanPlot && meanPlot.pointCIs) exp.results.push(meanPlot); + + super.handle(exp, data); // continue request + } +} diff --git a/api/src/engine/connectors/exareme/handlers/index.ts b/api/src/engine/connectors/exareme/handlers/index.ts index f9c6c47a8a5dd4dd9b12a817fe77054baf9e85d5..2d7d607d5dac7ebc553536c30f1a3b4c2acd6514 100644 --- a/api/src/engine/connectors/exareme/handlers/index.ts +++ b/api/src/engine/connectors/exareme/handlers/index.ts @@ -1,4 +1,5 @@ import { Experiment } from '../../../../engine/models/experiment/experiment.model'; +import AnovaOneWayHandler from './algorithms/anova-one-way.handler'; import AreaHandler from './algorithms/area.handler'; import DescriptiveHandler from './algorithms/descriptive.handler'; import HeatMapHandler from './algorithms/heat-map.handler'; @@ -12,6 +13,7 @@ start .setNext(new AreaHandler()) .setNext(new DescriptiveHandler()) .setNext(new HeatMapHandler()) + .setNext(new AnovaOneWayHandler()) .setNext(new PCAHandler()) .setNext(new RawHandler()); // should be last handler as it works as a fallback (if other handlers could not process the results) diff --git a/api/src/engine/connectors/exareme/main.connector.ts b/api/src/engine/connectors/exareme/main.connector.ts index 8d25f9ffd2b3eba957ee2bcbe73a7f704841b38a..02f28ae18223e74286b62b83a5e1aca03ce577d7 100644 --- a/api/src/engine/connectors/exareme/main.connector.ts +++ b/api/src/engine/connectors/exareme/main.connector.ts @@ -5,6 +5,7 @@ import { HttpStatus, Inject, Injectable, + InternalServerErrorException, } from '@nestjs/common'; import { AxiosRequestConfig } from 'axios'; import { Request } from 'express'; @@ -45,7 +46,6 @@ type Headers = Record<string, string>; @Injectable() export default class ExaremeService implements IEngineService { - headers = {}; constructor( @Inject(ENGINE_MODULE_OPTIONS) private readonly options: IEngineOptions, private readonly httpService: HttpService, @@ -75,9 +75,7 @@ export default class ExaremeService implements IEngineService { this.options.baseurl + `experiments${isTransient ? '/transient' : ''}`; const resultAPI = await firstValueFrom( - this.post<ExperimentData>(request, path, form, { - headers: this.headers, - }), + this.post<ExperimentData>(request, path, form), ); return dataToExperiment(resultAPI.data); @@ -141,11 +139,7 @@ export default class ExaremeService implements IEngineService { const path = this.options.baseurl + `experiments/${id}`; try { - await firstValueFrom( - this.delete(request, path, { - headers: this.headers, - }), - ); + await firstValueFrom(this.delete(request, path)); return { id: id, }; @@ -158,11 +152,7 @@ export default class ExaremeService implements IEngineService { const path = this.options.baseurl + 'pathologies'; try { - const data = await firstValueFrom( - this.get<Pathology[]>(request, path, { - headers: this.headers, - }), - ); + const data = await firstValueFrom(this.get<Pathology[]>(request, path)); return ( data?.data @@ -192,20 +182,30 @@ export default class ExaremeService implements IEngineService { async getActiveUser(request: Request): Promise<User> { const path = this.options.baseurl + 'activeUser'; - const response = await firstValueFrom(this.get<string>(request, path)); - return transformToUser.evaluate(response.data); + try { + return transformToUser.evaluate(response.data); + } catch (e) { + new InternalServerErrorException('Cannot parse user data from Engine', e); + } } async updateUser(request: Request): Promise<User> { const path = this.options.baseurl + 'activeUser/agreeNDA'; - - this.post<string>(request, path, request.body).pipe( - map((response) => response.data), + const response = await firstValueFrom( + this.post<string>(request, path, { + agreeNDA: true, + }), ); - return this.getActiveUser(request); + try { + return transformToUser.evaluate(response.data); + } catch (e) { + throw new InternalServerErrorException( + 'Error when trying to parse user data from the engine', + ); + } } getAlgorithmsREST(request: Request): Observable<string> { @@ -256,7 +256,7 @@ export default class ExaremeService implements IEngineService { }; private getHeadersFromRequest(request: Request): Headers { - if (!request || request.headers) return {}; + if (!request || !request.headers) return {}; return request.headers as Headers; } diff --git a/api/src/engine/connectors/local/main.connector.ts b/api/src/engine/connectors/local/main.connector.ts index d46e1ae80105b5ca1e8a1d073707862631152543..600fcc9824046a54a017b76ca27033a0b15912de 100644 --- a/api/src/engine/connectors/local/main.connector.ts +++ b/api/src/engine/connectors/local/main.connector.ts @@ -11,7 +11,7 @@ import { ListExperiments } from 'src/engine/models/experiment/list-experiments.m import { User } from 'src/users/models/user.model'; export default class LocalService implements IEngineService { - login(): User | Promise<User> { + async login(): Promise<User> { return { id: '1', username: 'LocalServiceUser', diff --git a/api/src/engine/engine.interfaces.ts b/api/src/engine/engine.interfaces.ts index b8b21e9ab52e82ed911ee044e080ec401146f861..1a93078d70be9ded4272022f0dff5b85d2017e33 100644 --- a/api/src/engine/engine.interfaces.ts +++ b/api/src/engine/engine.interfaces.ts @@ -75,10 +75,7 @@ export interface IEngineService { * @param password * @returns User object or empty if user not found */ - login?( - username: string, - password: string, - ): Promise<User | undefined> | User | undefined; + login?(username: string, password: string): Promise<User | undefined>; getPassthrough?(suffix: string, req?: Request): Observable<string> | string; } diff --git a/api/src/engine/engine.resolver.ts b/api/src/engine/engine.resolver.ts index 2ea02a19025029f551ea684fa756ca7b842e82d6..6260111702f3268881409f80f5db91cc197c1cba 100644 --- a/api/src/engine/engine.resolver.ts +++ b/api/src/engine/engine.resolver.ts @@ -41,18 +41,20 @@ export class EngineResolver { @Public() configuration(): Configuration { const config = this.engineService.getConfiguration?.(); + const matomo = this.configSerivce.get('matomo'); const data = { ...(config ?? {}), - skipAuth: parseToBoolean( - this.configSerivce.get(authConstants.skipAuth), - true, - ), + connectorId: this.engineOptions.type, skipTos: parseToBoolean(this.configSerivce.get(ENGINE_SKIP_TOS)), enableSSO: parseToBoolean( this.configSerivce.get(authConstants.enableSSO), ), - connectorId: this.engineOptions.type, + skipAuth: parseToBoolean( + this.configSerivce.get(authConstants.skipAuth), + true, + ), + matomo, }; const version = Md5.hashStr(JSON.stringify(data)); diff --git a/api/src/engine/models/configuration.model.ts b/api/src/engine/models/configuration.model.ts index d2d390dc67e6bbd120932a382dc324a0d1e1f677..6ffe779cc55962c0dc0c20fd0e3d47c9e4d73348 100644 --- a/api/src/engine/models/configuration.model.ts +++ b/api/src/engine/models/configuration.model.ts @@ -1,4 +1,5 @@ import { Field, ObjectType } from '@nestjs/graphql'; +import { Matomo } from './configuration/matomo.model'; @ObjectType() export class Configuration { @Field() @@ -21,4 +22,7 @@ export class Configuration { @Field({ nullable: true, defaultValue: true }) enableSSO?: boolean; + + @Field(() => Matomo, { nullable: true }) + matomo?: Matomo; } diff --git a/api/src/engine/models/configuration/matomo.model.ts b/api/src/engine/models/configuration/matomo.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..62782ce3f4b63bfdee7cbf8821578dd04d9a6db5 --- /dev/null +++ b/api/src/engine/models/configuration/matomo.model.ts @@ -0,0 +1,13 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class Matomo { + @Field({ nullable: true, defaultValue: false }) + enabled?: boolean; + + @Field({ nullable: true }) + siteId?: string; + + @Field({ nullable: true }) + urlBase?: string; +} diff --git a/api/src/engine/models/result/common/result-union.model.ts b/api/src/engine/models/result/common/result-union.model.ts index 5c2ffc69597c6b1afc39ea624972586285d6d3e4..0e385962665d0ad0708c84b4597273b4678f55d8 100644 --- a/api/src/engine/models/result/common/result-union.model.ts +++ b/api/src/engine/models/result/common/result-union.model.ts @@ -1,10 +1,11 @@ import { createUnionType } from '@nestjs/graphql'; -import { BarChartResult } from '../bar-chart-result.model'; import { GroupsResult } from '../groups-result.model'; import { HeatMapResult } from '../heat-map-result.model'; import { LineChartResult } from '../line-chart-result.model'; import { RawResult } from '../raw-result.model'; import { TableResult } from '../table-result.model'; +import { BarChartResult } from '../bar-chart-result.model'; +import { MeanChartResult } from '../means-chart-result.model'; export const ResultUnion = createUnionType({ name: 'ResultUnion', @@ -15,6 +16,7 @@ export const ResultUnion = createUnionType({ HeatMapResult, LineChartResult, BarChartResult, + MeanChartResult, ], resolveType(value) { if (value.headers) { @@ -37,6 +39,10 @@ export const ResultUnion = createUnionType({ return BarChartResult; } + if (value.pointCIs) { + return MeanChartResult; + } + return RawResult; }, }); diff --git a/api/src/engine/models/result/means-chart-result.model.ts b/api/src/engine/models/result/means-chart-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..12dd97416c4ab90afd28d3c26556ba66a8c9ebe5 --- /dev/null +++ b/api/src/engine/models/result/means-chart-result.model.ts @@ -0,0 +1,33 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { ChartAxis } from './common/chart-axis.model'; +import { Result } from './common/result.model'; + +@ObjectType() +export class PointCI { + @Field({ nullable: true }) + min?: number; + + @Field() + mean: number; + + @Field({ nullable: true }) + max?: number; +} + +@ObjectType() +export class MeanChartResult extends Result { + @Field() + name: string; + + @Field(() => ChartAxis, { nullable: true }) + xAxis?: ChartAxis; + + @Field(() => ChartAxis, { nullable: true }) + yAxis?: ChartAxis; + + @Field(() => [PointCI], { + description: 'List of points with confidence information: min, mean, max', + defaultValue: [], + }) + pointCIs: PointCI[]; +} diff --git a/api/src/engine/models/result/table-result.model.ts b/api/src/engine/models/result/table-result.model.ts index a733683d607f6f8bfbaa9d6cec5884620a76cceb..85fa6eeba8c7f2eba961fb9773159190f313923a 100644 --- a/api/src/engine/models/result/table-result.model.ts +++ b/api/src/engine/models/result/table-result.model.ts @@ -2,13 +2,13 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Header } from './common/header.model'; import { Result } from './common/result.model'; -export enum ThemeType { +export enum TableStyle { DEFAULT, NORMAL, } -registerEnumType(ThemeType, { - name: 'ThemeType', +registerEnumType(TableStyle, { + name: 'TableStyle', }); @ObjectType() @@ -22,6 +22,6 @@ export class TableResult extends Result { @Field(() => [Header]) headers: Header[]; - @Field(() => ThemeType, { defaultValue: ThemeType.DEFAULT, nullable: true }) - theme?: ThemeType; + @Field(() => TableStyle, { defaultValue: TableStyle.DEFAULT, nullable: true }) + tableStyle?: TableStyle; } diff --git a/api/src/files/files.controller.ts b/api/src/files/files.controller.ts index 91f3f8549eaf05822ed51d456b29dc03e49a5a99..8ca4ba83f0632dcf4c8f9150dbd8ace0469450b6 100644 --- a/api/src/files/files.controller.ts +++ b/api/src/files/files.controller.ts @@ -20,9 +20,10 @@ export class FilesController { @Res() response: Response, @Param('name') filename: string, ) { + const proto = request.headers['x-forwarded-proto'] ?? request.protocol; if (filename.endsWith('.md')) { const baseurl = - request.protocol + + proto + '://' + join(request.get('host'), process.env.BASE_URL_CONTEXT ?? '', 'assets'); // not full url, should consider "/services" const text = this.filesService.getMarkdown(filename, baseurl); diff --git a/api/src/main/app.module.ts b/api/src/main/app.module.ts index 56ef2df6fe32a0198a818a8b035418c44e54ad76..ebc1be97cb1be3d02a48c3ec4d561280df249b03 100644 --- a/api/src/main/app.module.ts +++ b/api/src/main/app.module.ts @@ -7,6 +7,7 @@ import { GraphQLError } from 'graphql'; import { join } from 'path'; import { AuthModule } from 'src/auth/auth.module'; import dbConfig from 'src/config/db.config'; +import matomoConfig from 'src/config/matomo.config'; import { EngineModule } from 'src/engine/engine.module'; import { FilesModule } from 'src/files/files.module'; import { UsersModule } from 'src/users/users.module'; @@ -18,7 +19,7 @@ import { AppService } from './app.service'; ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env', '.env.defaults'], - load: [dbConfig], + load: [dbConfig, matomoConfig], }), GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, @@ -53,7 +54,6 @@ import { AppService } from './app.service'; migrations: ['dist/migrations/*{.ts,.js}'], migrationsRun: process.env.NODE_ENV !== 'dev', synchronize: process.env.NODE_ENV === 'dev', - loggerLevel: 'debug', autoLoadEntities: true, }), }), diff --git a/api/src/schema.gql b/api/src/schema.gql index 8a0e2400bf297c9d066ef68dc77ec2cee3b7672f..8f5b0feba8b4b7d1c700ddf1d9e02563d532fe83 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -14,6 +14,12 @@ type AuthenticationOutput { accessToken: String! } +type Matomo { + enabled: Boolean + siteId: String + urlBase: String +} + type Configuration { connectorId: String! hasGalaxy: Boolean @@ -22,6 +28,7 @@ type Configuration { skipAuth: Boolean skipTos: Boolean enableSSO: Boolean + matomo: Matomo } type Dataset { @@ -85,30 +92,22 @@ type Algorithm { description: String } -type ChartAxis { - """label of the Axis""" - label: String - - """label of each element on this Axis""" - categories: [String!] -} - type GroupResult { name: String! description: String results: [ResultUnion!]! } -union ResultUnion = TableResult | RawResult | GroupsResult | HeatMapResult | LineChartResult | BarChartResult +union ResultUnion = TableResult | RawResult | GroupsResult | HeatMapResult | LineChartResult | BarChartResult | MeanChartResult type TableResult { name: String! data: [[String!]!]! headers: [Header!]! - theme: ThemeType + tableStyle: TableStyle } -enum ThemeType { +enum TableStyle { DEFAULT NORMAL } @@ -157,6 +156,23 @@ type BarChartResult { hasConnectedBars: Boolean } +type MeanChartResult { + name: String! + xAxis: ChartAxis + yAxis: ChartAxis + + """List of points with confidence information: min, mean, max""" + pointCIs: [PointCI!]! +} + +type ChartAxis { + """label of the Axis""" + label: String + + """label of each element on this Axis""" + categories: [String!] +} + type ExtraLineInfo { label: String! values: [String!]! @@ -180,6 +196,12 @@ type Header { type: String! } +type PointCI { + min: Float + mean: Float! + max: Float +} + type Author { username: String fullname: String diff --git a/api/src/users/users.resolver.ts b/api/src/users/users.resolver.ts index 13ec63ae73dc528d0b9c1dd1a0425f7854045f4e..0b78a0fb5af9b0dfb7de5a8d727e418954697de0 100644 --- a/api/src/users/users.resolver.ts +++ b/api/src/users/users.resolver.ts @@ -76,7 +76,11 @@ export class UsersResolver { @CurrentUser() user?: User, ) { if (this.engineService.updateUser) - return this.engineService.updateUser(request, user?.id, updateUserInput); + return await this.engineService.updateUser( + request, + user?.id, + updateUserInput, + ); await this.usersService.update(user.id, updateUserInput); diff --git a/for-developers/authentication.md b/for-developers/authentication.md new file mode 100644 index 0000000000000000000000000000000000000000..bed3b7e54346a681b603aa11ad401be6dce03a65 --- /dev/null +++ b/for-developers/authentication.md @@ -0,0 +1,92 @@ +# Authentication + +The authentication implementation is based on [passport.js](https://www.passportjs.org) it allows a flexible way to implement different strategies inside the gateway.  + +For now the authentication system is quite simple and only use JWT. The real implementation of authorization and authentication is left to the connector.  + +#### How it works ? + +The communication between the frontend and the gateway is handled by JWT token who contains user information such as his username. + +.png>) + +The gateway will handle the authentication process with the frontend in a unique fashion always using a JWT token. This token can contains information specific to some connector. For that purpose the user model contains a field `extraFields` which basically a dictionary.  + +{% code title="user.model.ts" %} +```typescript +import { Field, ObjectType } from '@nestjs/graphql'; +import { Entity, PrimaryColumn, Column } from 'typeorm'; + +@Entity({ name: 'user' }) +@ObjectType() +export class User { + @PrimaryColumn() + @Field() + id: string; + + @Field() + username: string; + + @Field({ nullable: true }) + fullname?: string; + + @Field({ nullable: true }) + email?: string; + + @Column({ nullable: true, default: false }) + @Field({ nullable: true }) + agreeNDA?: boolean; + + extraFields?: Record<string, any>; +} +``` +{% endcode %} + +This field can be used by the connector to store information related to the user as other token for engine API endpoints. + +#### Login  + +The real login system is delegated to the connector by using the `login` method in the interface. + +{% code title="engine.interface.ts" %} +```typescript +export interface IEngineService { + // ... + + /** + * Method that login a user with username and password + * @param username + * @param password + * @returns User object or empty if user not found + */ + login?( + username: string, + password: string, + ): Promise<User | undefined>; + + // ... +} +``` +{% endcode %} + +This method can be optional as the authentication can be made by a 3rd party system under the same domain as this is the case for `exareme`. + +When the login is performed, this function should return a `User` object and can feed the `extraFields` attribute with data needed to perform future request to the engine. + +#### Logout + +The same mechanism is applied to the logout system using the method logout from the engine. + +{% code title="engine.interface.ts" %} +```typescript +export interface IEngineService { + // ... + + logout?(req: Request + ): Promise<void>; + + // ... +} +``` +{% endcode %} +