diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..a99622b60a0fc5de6105d2845a71ffa5a0a2de8b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,24 @@ +image: docker:20 +services: + - docker:20-dind + +stages: + - build + - release + - deploy + +variables: + CONTAINER_BASE_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG + +before_script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + +build: + stage: build + script: + - docker build -t $CONTAINER_BASE_IMAGE ./api + - docker push $CONTAINER_BASE_IMAGE + only: + - main + - develop + - /^release-.*$/ diff --git a/api/.env.defaults b/api/.env.defaults new file mode 100644 index 0000000000000000000000000000000000000000..ad9987672442b84904a71d8526b7e050267b3924 --- /dev/null +++ b/api/.env.defaults @@ -0,0 +1,3 @@ +ENGINE_TYPE=exareme +ENGINE_BASE_URL=http://127.0.0.1:8080/services/ +GATEWAY_PORT=8081 diff --git a/api/.eslintrc.js b/api/.eslintrc.js index f6c62bee279534a2a2ce7ee4424670655fbd2a4c..8e34f1ea92b73633f2be969865474d6ca4e5cb9c 100644 --- a/api/.eslintrc.js +++ b/api/.eslintrc.js @@ -20,5 +20,6 @@ module.exports = { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', + 'prettier/prettier': ['error', { "endOfLine": "auto"}, { usePrettierrc: true }], }, }; diff --git a/api/.gitignore b/api/.gitignore index 22f55adc5647206db11558139164c3deb77f5c01..5ecdd35f3ffc85cf6a6c620d543d0cb4d31c7354 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -32,4 +32,7 @@ lerna-debug.log* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json -!.vscode/extensions.json \ No newline at end of file +!.vscode/extensions.json + +# env +.env \ No newline at end of file diff --git a/api/.husky/pre-commit b/api/.husky/pre-commit new file mode 100755 index 0000000000000000000000000000000000000000..6f13889f0984140d0b831a129b3818e7f3760425 --- /dev/null +++ b/api/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +cd api +npm run lint diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d843415a4cad9d1908b9afa723589ed6b75579cb --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,33 @@ +FROM node:12.13-alpine As development + +WORKDIR /usr/src/app + +COPY package*.json ./ + +RUN npm ci --development + +COPY . . + +RUN npm run build + +RUN npm run test + +FROM node:12.13-alpine as production + +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} + +RUN apk update && apk add bash +RUN apk add --no-cache bash + +WORKDIR /usr/src/app + +COPY package*.json ./ + +RUN npm ci --production + +COPY . . + +COPY --from=development /usr/src/app/dist ./dist + +CMD ["node", "dist/main"] diff --git a/api/package-lock.json b/api/package-lock.json index c564f292ce87de51fe2f29be8fab6f19a2c06ceb..88182859b9a42510792332bff648111830b823b3 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@nestjs/axios": "^0.0.1", "@nestjs/common": "^8.0.0", + "@nestjs/config": "^1.0.1", "@nestjs/core": "^8.0.0", "@nestjs/graphql": "^9.0.4", "@nestjs/platform-express": "^8.0.0", @@ -18,6 +19,8 @@ "apollo-server-express": "^3.3.0", "axios": "^0.21.1", "graphql": "^15.5.3", + "graphql-type-json": "^0.3.2", + "jsonata": "^1.8.5", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0" @@ -35,6 +38,7 @@ "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", + "husky": "^7.0.2", "jest": "^27.0.6", "prettier": "^2.3.2", "supertest": "^6.1.3", @@ -1765,6 +1769,32 @@ } } }, + "node_modules/@nestjs/config": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-1.0.1.tgz", + "integrity": "sha512-azMl4uYlFIhYsywFxPJT81RxF3Pnn0TZW3EEmr0Wa0Wex8R2xpvBNrCcrOgW3TB1xGMP7eqBrlfsVh5ZP82szg==", + "dependencies": { + "dotenv": "10.0.0", + "dotenv-expand": "5.1.0", + "lodash.get": "4.4.2", + "lodash.has": "4.5.2", + "lodash.set": "4.3.2", + "uuid": "8.3.2" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^6.0.0 || ^7.2.0" + } + }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "engines": { + "node": ">=10" + } + }, "node_modules/@nestjs/core": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-8.0.6.tgz", @@ -4392,6 +4422,11 @@ "node": ">=10" } }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5548,6 +5583,14 @@ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" } }, + "node_modules/graphql-type-json": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz", + "integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==", + "peerDependencies": { + "graphql": ">=0.8.0" + } + }, "node_modules/graphql-ws": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.4.0.tgz", @@ -5695,6 +5738,21 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.2.tgz", + "integrity": "sha512-8yKEWNX4z2YsofXAMT7KvA1g8p+GxtB1ffV8XtpAEGuXNAbCV5wdNKH+qTpw8SM9fh4aMPDR+yQuKfgnreyZlg==", + "dev": true, + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -7124,6 +7182,14 @@ "node": ">=6" } }, + "node_modules/jsonata": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-1.8.5.tgz", + "integrity": "sha512-ilDyTBkg6qhNoNVr8PUPzz5GYvRK+REKOM5MdOGzH2y6V4yvPRMegSvbZLpbTtI0QAgz09QM7drDhSHUlwp9pA==", + "engines": { + "node": ">= 8" + } + }, "node_modules/jsonc-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", @@ -7217,12 +7283,27 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "node_modules/lodash.has": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz", + "integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -11891,6 +11972,26 @@ "uuid": "8.3.2" } }, + "@nestjs/config": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-1.0.1.tgz", + "integrity": "sha512-azMl4uYlFIhYsywFxPJT81RxF3Pnn0TZW3EEmr0Wa0Wex8R2xpvBNrCcrOgW3TB1xGMP7eqBrlfsVh5ZP82szg==", + "requires": { + "dotenv": "10.0.0", + "dotenv-expand": "5.1.0", + "lodash.get": "4.4.2", + "lodash.has": "4.5.2", + "lodash.set": "4.3.2", + "uuid": "8.3.2" + }, + "dependencies": { + "dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==" + } + } + }, "@nestjs/core": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-8.0.6.tgz", @@ -13914,6 +14015,11 @@ "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", "peer": true }, + "dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -14791,6 +14897,12 @@ "tslib": "^2.1.0" } }, + "graphql-type-json": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/graphql-type-json/-/graphql-type-json-0.3.2.tgz", + "integrity": "sha512-J+vjof74oMlCWXSvt0DOf2APEdZOCdubEvGDUAlqH//VBYcOYsGgRW7Xzorr44LvkjiuvecWc8fChxuZZbChtg==", + "requires": {} + }, "graphql-ws": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.4.0.tgz", @@ -14901,6 +15013,12 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, + "husky": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.2.tgz", + "integrity": "sha512-8yKEWNX4z2YsofXAMT7KvA1g8p+GxtB1ffV8XtpAEGuXNAbCV5wdNKH+qTpw8SM9fh4aMPDR+yQuKfgnreyZlg==", + "dev": true + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -15985,6 +16103,11 @@ "minimist": "^1.2.5" } }, + "jsonata": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-1.8.5.tgz", + "integrity": "sha512-ilDyTBkg6qhNoNVr8PUPzz5GYvRK+REKOM5MdOGzH2y6V4yvPRMegSvbZLpbTtI0QAgz09QM7drDhSHUlwp9pA==" + }, "jsonc-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.0.0.tgz", @@ -16061,12 +16184,27 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "lodash.has": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.has/-/lodash.has-4.5.2.tgz", + "integrity": "sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI=" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", diff --git a/api/package.json b/api/package.json index c467c3162a253b554f8c90d90e10cff550646752..1885442d70f4ac545ac7fc662ec9bef27d1d21de 100644 --- a/api/package.json +++ b/api/package.json @@ -18,11 +18,13 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "prepare": "cd .. && husky install api/.husky" }, "dependencies": { "@nestjs/axios": "^0.0.1", "@nestjs/common": "^8.0.0", + "@nestjs/config": "^1.0.1", "@nestjs/core": "^8.0.0", "@nestjs/graphql": "^9.0.4", "@nestjs/platform-express": "^8.0.0", @@ -30,6 +32,8 @@ "apollo-server-express": "^3.3.0", "axios": "^0.21.1", "graphql": "^15.5.3", + "graphql-type-json": "^0.3.2", + "jsonata": "^1.8.5", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^7.2.0" @@ -47,6 +51,7 @@ "eslint": "^7.30.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^3.4.0", + "husky": "^7.0.2", "jest": "^27.0.6", "prettier": "^2.3.2", "supertest": "^6.1.3", diff --git a/api/src/common/interfaces/utilities.interface.ts b/api/src/common/interfaces/utilities.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddf5dedfa9585bdffa46ef5a90ca643d49eacf7a --- /dev/null +++ b/api/src/common/interfaces/utilities.interface.ts @@ -0,0 +1 @@ +export type Dictionary<T> = { [key: string]: T }; diff --git a/api/src/engine/connectors/csv/main.connector.ts b/api/src/engine/connectors/csv/main.connector.ts new file mode 100644 index 0000000000000000000000000000000000000000..05864adb55c7141de3ec49bd6854b5fdcdb793ee --- /dev/null +++ b/api/src/engine/connectors/csv/main.connector.ts @@ -0,0 +1,177 @@ +import { firstValueFrom, Observable } from 'rxjs'; +import { IEngineOptions, IEngineService } from 'src/engine/engine.interfaces'; +import { Domain } from 'src/engine/models/domain.model'; +import { ExperimentCreateInput } from 'src/engine/models/experiment/input/experiment-create.input'; +import { + Experiment, + PartialExperiment, +} from 'src/engine/models/experiment/experiment.model'; +import { ListExperiments } from 'src/engine/models/experiment/list-experiments.model'; +import { ExperimentEditInput } from 'src/engine/models/experiment/input/experiment-edit.input'; +import { Algorithm } from 'src/engine/models/experiment/algorithm.model'; +import { HttpService } from '@nestjs/axios'; +import { Group } from 'src/engine/models/group.model'; +import { Dictionary } from 'src/common/interfaces/utilities.interface'; + +export default class CSVService implements IEngineService { + constructor( + private readonly options: IEngineOptions, + private readonly httpService: HttpService, + ) {} + + logout() { + throw new Error('Method not implemented.'); + } + + getAlgorithms(): Algorithm[] | Promise<Algorithm[]> { + throw new Error('Method not implemented.'); + } + + createExperiment( + data: ExperimentCreateInput, + isTransient: boolean, + ): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + listExperiments( + page: number, + name: string, + ): ListExperiments | Promise<ListExperiments> { + throw new Error('Method not implemented.'); + } + + getExperiment(uuid: string): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + removeExperiment( + uuid: string, + ): PartialExperiment | Promise<PartialExperiment> { + throw new Error('Method not implemented.'); + } + + editExperient( + uuid: string, + expriment: ExperimentEditInput, + ): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + async getDomains(): Promise<Domain[]> { + const path = this.options.baseurl; + + const { data } = await firstValueFrom(this.httpService.get<string>(path)); + + const rows = data + .split('\r\n') + .map((row) => row.split('\t').filter((i) => i)) + .filter((row) => row.length >= 2); + + rows.shift(); // remove headers + + const vars = []; + const groups: Dictionary<Group> = {}; + const rootGroup: Group = { + id: 'Global group', + groups: [], + }; + + rows.forEach((row) => { + const variable = { + id: row[0].toLowerCase(), + label: row[0], + }; + + row.shift(); // get ride of the variable name, keep only groups + + vars.push(variable); + + row + .filter((group) => !groups[group.toLowerCase()]) + .forEach((group, i) => { + const groupId = group.toLowerCase(); + if (i === 0) rootGroup.groups.push(groupId); + groups[groupId] = { + id: groupId, + label: group, + variables: [], + groups: [], + }; + }); + + const groupId = row[row.length - 1].toLowerCase(); // group's variable container + + groups[groupId].variables.push(variable.id); // add variable + + row + .reverse() + .map((group) => group.toLowerCase()) + .forEach((group, i) => { + const groupId = group.toLowerCase(); + + if (i !== row.length - 1) { + const parentId = row[i + 1].toLowerCase(); + if (groups[parentId].groups.indexOf(groupId) === -1) + groups[parentId].groups.push(groupId); + } + }); + }); + + rootGroup.groups = [...new Set(rootGroup.groups)]; // get distinct values + + return [ + { + id: 'Dummy', + label: 'Dummy', + datasets: [{ id: 'DummyDataset', label: 'Dummy Dataset' }], + groups: Object.values(groups), + rootGroup: rootGroup, + variables: vars, + }, + ]; + } + + getActiveUser(): string { + const dummyUser = { + username: 'anonymous', + subjectId: 'anonymousId', + fullname: 'anonymous', + email: 'anonymous@anonymous.com', + agreeNDA: true, + }; + return JSON.stringify(dummyUser); + } + + editActiveUser(): Observable<string> { + throw new Error('Method not implemented.'); + } + + getExperimentREST(): Observable<string> { + throw new Error('Method not implemented.'); + } + + deleteExperiment(): Observable<string> { + throw new Error('Method not implemented.'); + } + + editExperimentREST(): Observable<string> { + throw new Error('Method not implemented.'); + } + + startExperimentTransient(): Observable<string> { + throw new Error('Method not implemented.'); + } + + startExperiment(): Observable<string> { + throw new Error('Method not implemented.'); + } + + getExperiments(): string { + return '[]'; + } + + getAlgorithmsREST(): string { + return '[]'; + } +} diff --git a/api/src/engine/connectors/datashield/main.connector.ts b/api/src/engine/connectors/datashield/main.connector.ts index f3a15d7680b7bb4f0d167b565160f8e010f25d13..4df8aa14bf1fda0bdec3a4d21c98cc79277cdd83 100644 --- a/api/src/engine/connectors/datashield/main.connector.ts +++ b/api/src/engine/connectors/datashield/main.connector.ts @@ -1,10 +1,92 @@ -import { HttpService } from "@nestjs/axios"; -import { IEngineOptions, IEngineService } from "src/engine/engine.interface"; +import { Observable } from 'rxjs'; +import { IEngineService } from 'src/engine/engine.interfaces'; +import { Domain } from 'src/engine/models/domain.model'; +import { ExperimentCreateInput } from 'src/engine/models/experiment/input/experiment-create.input'; +import { + Experiment, + PartialExperiment, +} from 'src/engine/models/experiment/experiment.model'; +import { ListExperiments } from 'src/engine/models/experiment/list-experiments.model'; +import { ExperimentEditInput } from 'src/engine/models/experiment/input/experiment-edit.input'; +import { Algorithm } from 'src/engine/models/experiment/algorithm.model'; export default class DataShieldService implements IEngineService { - constructor(private readonly options: IEngineOptions, private readonly httpService: HttpService) { } + logout(): void { + throw new Error('Method not implemented.'); + } - demo(): string { - return "datashield"; - } -} \ No newline at end of file + getAlgorithms(): Algorithm[] | Promise<Algorithm[]> { + throw new Error('Method not implemented.'); + } + + createExperiment( + data: ExperimentCreateInput, + isTransient: boolean, + ): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + listExperiments( + page: number, + name: string, + ): ListExperiments | Promise<ListExperiments> { + throw new Error('Method not implemented.'); + } + + getExperiment(uuid: string): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + removeExperiment( + uuid: string, + ): PartialExperiment | Promise<PartialExperiment> { + throw new Error('Method not implemented.'); + } + + editExperient( + uuid: string, + expriment: ExperimentEditInput, + ): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + getDomains(): Domain[] { + throw new Error('Method not implemented.'); + } + + getActiveUser(): string { + throw new Error('Method not implemented.'); + } + + editActiveUser(): Observable<string> { + throw new Error('Method not implemented.'); + } + + getExperimentREST(): Observable<string> { + throw new Error('Method not implemented.'); + } + + deleteExperiment(): Observable<string> { + throw new Error('Method not implemented.'); + } + + editExperimentREST(): Observable<string> { + throw new Error('Method not implemented.'); + } + + startExperimentTransient(): Observable<string> { + throw new Error('Method not implemented.'); + } + + startExperiment(): Observable<string> { + throw new Error('Method not implemented.'); + } + + getExperiments(): string { + throw new Error('Method not implemented.'); + } + + getAlgorithmsREST(): string { + throw new Error('Method not implemented.'); + } +} diff --git a/api/src/engine/connectors/exareme/converters.ts b/api/src/engine/connectors/exareme/converters.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9bc1d7c51908d9b833500d70e573774b908a221 --- /dev/null +++ b/api/src/engine/connectors/exareme/converters.ts @@ -0,0 +1,197 @@ +import { Category } from 'src/engine/models/category.model'; +import { AlgorithmParameter } from 'src/engine/models/experiment/algorithm-parameter.model'; +import { Algorithm } from 'src/engine/models/experiment/algorithm.model'; +import { Experiment } from 'src/engine/models/experiment/experiment.model'; +import { ExperimentCreateInput } from 'src/engine/models/experiment/input/experiment-create.input'; +import { Group } from 'src/engine/models/group.model'; +import { ResultUnion } from 'src/engine/models/result/common/result-union.model'; +import { + GroupResult, + GroupsResult, +} from 'src/engine/models/result/groups-result.model'; +import { RawResult } from 'src/engine/models/result/raw-result.model'; +import { Variable } from 'src/engine/models/variable.model'; +import { Entity } from './interfaces/entity.interface'; +import { ExperimentData } from './interfaces/experiment/experiment.interface'; +import { ResultExperiment } from './interfaces/experiment/result-experiment.interface'; +import { Hierarchy } from './interfaces/hierarchy.interface'; +import { VariableEntity } from './interfaces/variable-entity.interface'; +import { + descriptiveModelToTables, + descriptiveSingleToTables, + transformToAlgorithms, + transformToExperiment, +} from './transformations'; + +export const dataToGroup = (data: Hierarchy): Group => { + return { + id: data.code, + label: data.label, + groups: data.groups + ? data.groups.map(dataToGroup).map((group) => group.id) + : [], + variables: data.variables + ? data.variables.map((data: VariableEntity) => data.code) + : [], + }; +}; + +export const dataToCategory = (data: Entity): Category => { + return { + id: data.code, + label: data.label, + }; +}; + +export const dataToVariable = (data: VariableEntity): Variable => { + return { + id: data.code, + label: data.label, + type: data.type, + description: data.description, + enumerations: data.enumerations + ? data.enumerations.map(dataToCategory) + : [], + groups: [], + }; +}; + +const algoParamInputToData = (param: AlgorithmParameter) => { + return { + name: param.name, + label: param.name, + value: param.value.join(','), + }; +}; + +export const experimentInputToData = (data: ExperimentCreateInput) => { + const formula = + ((data.transformations?.length > 0 || data.interactions?.length > 0) && { + single: + data.transformations?.map((t) => ({ + var_name: t.name, + unary_operation: t.operation, + })) || [], + interactions: + data.interactions?.map((v) => + v.reduce((a, e, i) => ({ ...a, [`var${i + 1}`]: e }), {}), + ) || [], + }) || + null; + + return { + algorithm: { + parameters: [ + { + name: 'dataset', + label: 'dataset', + value: data.datasets.join(','), + }, + { + name: 'filter', + label: 'filter', + value: data.filter, + }, + { + name: 'pathology', + label: 'pathology', + value: data.domain, + }, + { + name: 'y', + label: 'y', + value: data.variables.join(','), + }, + ...(formula + ? [ + { + name: 'formula', + value: JSON.stringify(formula), + }, + ] + : []), + ].concat(data.algorithm.parameters.map(algoParamInputToData)), + type: data.algorithm.type ?? 'string', + name: data.algorithm.name, + }, + name: data.name, + }; +}; + +export const descriptiveDataToTableResult = ( + data: ResultExperiment, +): GroupsResult[] => { + const result = new GroupsResult(); + + result.groups = [ + new GroupResult({ + name: 'Variables', + description: 'Descriptive statistics for the variables of interest.', + results: descriptiveSingleToTables.evaluate(data), + }), + ]; + + result.groups.push( + new GroupResult({ + name: 'Model', + description: + 'Intersection table for the variables of interest as it appears in the experiment.', + results: descriptiveModelToTables.evaluate(data), + }), + ); + + return [result]; +}; + +export const dataToExperiment = (data: ExperimentData): Experiment => { + const expTransform = transformToExperiment.evaluate(data); + + const exp: Experiment = { + ...expTransform, + results: [], + }; + + exp.results = data.result + ? data.result + .map((result) => dataToResult(result, exp.algorithm.name)) + .flat() + : []; + + return exp; +}; + +export const dataToAlgorithms = (data: string): Algorithm[] => { + return transformToAlgorithms.evaluate(data); +}; + +export const dataToRaw = (result: ResultExperiment): RawResult[] => { + return [ + { + rawdata: result.data, + }, + ]; +}; + +export const dataToResult = ( + result: ResultExperiment, + algo: string, +): Array<typeof ResultUnion> => { + switch (result.type.toLowerCase()) { + case 'application/json': + return dataJSONtoResult(result, algo); + default: + return dataToRaw(result); + } +}; + +export const dataJSONtoResult = ( + result: ResultExperiment, + algo: string, +): Array<typeof ResultUnion> => { + switch (algo.toLowerCase()) { + case 'descriptive_stats': + return descriptiveDataToTableResult(result); + default: + return []; + } +}; diff --git a/api/src/engine/connectors/exareme/interfaces/entity.interface.ts b/api/src/engine/connectors/exareme/interfaces/entity.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..38e3ab4125544a5b3eedf6f824533042f3c9d7d3 --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/entity.interface.ts @@ -0,0 +1,4 @@ +export interface Entity { + code: string; + label?: string; +} diff --git a/api/src/engine/connectors/exareme/interfaces/experiment/experiment.interface.ts b/api/src/engine/connectors/exareme/interfaces/experiment/experiment.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..7412cb0560c9eda88fe61dd4ebbf8128c94e2c62 --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/experiment/experiment.interface.ts @@ -0,0 +1,11 @@ +import { ResultExperiment } from './result-experiment.interface'; + +export interface ExperimentData { + name: string; + uuid?: string; + status?: string; + createdBy?: string; + shared?: boolean; + viewed?: boolean; + result?: ResultExperiment[]; +} diff --git a/api/src/engine/connectors/exareme/interfaces/experiment/experiments.interface.ts b/api/src/engine/connectors/exareme/interfaces/experiment/experiments.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ef0c0f84568d7a7e87dd1f759c1db3b17e8066a --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/experiment/experiments.interface.ts @@ -0,0 +1,8 @@ +import { ExperimentData } from './experiment.interface'; + +export interface ExperimentsData { + experiments: ExperimentData[]; + currentpage?: number; + totalExperiments?: number; + totalPages?: number; +} diff --git a/api/src/engine/connectors/exareme/interfaces/experiment/result-experiment.interface.ts b/api/src/engine/connectors/exareme/interfaces/experiment/result-experiment.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..e655151c9e7b113a2dbff0402d8e99c61534adee --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/experiment/result-experiment.interface.ts @@ -0,0 +1,4 @@ +export interface ResultExperiment { + data: unknown; + type: string; +} diff --git a/api/src/engine/connectors/exareme/interfaces/hierarchy.interface.ts b/api/src/engine/connectors/exareme/interfaces/hierarchy.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5017f13c8d24134de164eb2a4da9064c69c4fe0 --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/hierarchy.interface.ts @@ -0,0 +1,8 @@ +import { VariableEntity } from './variable-entity.interface'; + +export interface Hierarchy { + code: string; + label: string; + groups: Hierarchy[]; + variables: VariableEntity[]; +} diff --git a/api/src/engine/connectors/exareme/interfaces/pathology.interface.ts b/api/src/engine/connectors/exareme/interfaces/pathology.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..e71e3a0251af2e03aa7e7995e6129609a9140cd0 --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/pathology.interface.ts @@ -0,0 +1,9 @@ +import { Hierarchy } from './hierarchy.interface'; +import { VariableEntity } from './variable-entity.interface'; + +export interface Pathology { + code: string; + label: string; + datasets: VariableEntity[]; + metadataHierarchy: Hierarchy; +} diff --git a/api/src/engine/connectors/exareme/interfaces/variable-entity.interface.ts b/api/src/engine/connectors/exareme/interfaces/variable-entity.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..06c880dcb97fb9e203a51067fe265c75492fef58 --- /dev/null +++ b/api/src/engine/connectors/exareme/interfaces/variable-entity.interface.ts @@ -0,0 +1,9 @@ +import { Entity } from './entity.interface'; + +export interface VariableEntity extends Entity { + type?: 'nominal' | 'ordinal' | 'real' | 'integer' | 'text' | 'date'; + description?: string; + enumerations?: Entity[]; + group?: Entity[]; + info?: string; +} diff --git a/api/src/engine/connectors/exareme/main.connector.ts b/api/src/engine/connectors/exareme/main.connector.ts index e80647b1c6fa3cfd4c5e03d3315a1b8e1abb2ebd..f096ddde5642606b8f3196e2e00f182d326268b2 100644 --- a/api/src/engine/connectors/exareme/main.connector.ts +++ b/api/src/engine/connectors/exareme/main.connector.ts @@ -1,10 +1,256 @@ -import { HttpService } from "@nestjs/axios"; -import { IEngineOptions, IEngineService } from "src/engine/engine.interface"; +import { HttpService } from '@nestjs/axios'; +import { + BadRequestException, + HttpException, + HttpStatus, + Inject, + Injectable, +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; +import { firstValueFrom, map, Observable } from 'rxjs'; +import { ENGINE_MODULE_OPTIONS } from 'src/engine/engine.constants'; +import { IEngineOptions, IEngineService } from 'src/engine/engine.interfaces'; +import { Domain } from 'src/engine/models/domain.model'; +import { Algorithm } from 'src/engine/models/experiment/algorithm.model'; +import { + Experiment, + PartialExperiment, +} from 'src/engine/models/experiment/experiment.model'; +import { ExperimentCreateInput } from 'src/engine/models/experiment/input/experiment-create.input'; +import { ExperimentEditInput } from 'src/engine/models/experiment/input/experiment-edit.input'; +import { ListExperiments } from 'src/engine/models/experiment/list-experiments.model'; +import { Group } from 'src/engine/models/group.model'; +import { Variable } from 'src/engine/models/variable.model'; +import { + dataToAlgorithms, + dataToCategory, + dataToExperiment, + dataToGroup, + dataToVariable, + experimentInputToData, +} from './converters'; +import { ExperimentData } from './interfaces/experiment/experiment.interface'; +import { ExperimentsData } from './interfaces/experiment/experiments.interface'; +import { Hierarchy } from './interfaces/hierarchy.interface'; +import { Pathology } from './interfaces/pathology.interface'; +@Injectable() export default class ExaremeService implements IEngineService { - constructor(private readonly options: IEngineOptions, private readonly httpService: HttpService) { } + constructor( + @Inject(ENGINE_MODULE_OPTIONS) private readonly options: IEngineOptions, + private readonly httpService: HttpService, + @Inject(REQUEST) private readonly req: Request, + ) {} - demo(): string { - return "exareme" + async logout() { + const path = `${this.options.baseurl}logout`; + + await firstValueFrom(this.httpService.get(path)); + } + + async createExperiment( + data: ExperimentCreateInput, + isTransient = false, + ): Promise<Experiment> { + const form = experimentInputToData(data); + + const path = + this.options.baseurl + `experiments${isTransient ? '/transient' : ''}`; + + const resultAPI = await firstValueFrom( + this.httpService.post<ExperimentData>(path, form), + ); + + return dataToExperiment(resultAPI.data); + } + + async listExperiments(page: number, name: string): Promise<ListExperiments> { + const path = this.options.baseurl + 'experiments'; + + const resultAPI = await firstValueFrom( + this.httpService.get<ExperimentsData>(path, { params: { page, name } }), + ); + + return { + ...resultAPI.data, + experiments: resultAPI.data.experiments.map(dataToExperiment), + }; + } + + async getAlgorithms(): Promise<Algorithm[]> { + const path = this.options.baseurl + 'algorithms'; + + const resultAPI = await firstValueFrom(this.httpService.get<string>(path)); + + return dataToAlgorithms(resultAPI.data); + } + + async getExperiment(uuid: string): Promise<Experiment> { + const path = this.options.baseurl + `experiments/${uuid}`; + + const resultAPI = await firstValueFrom( + this.httpService.get<ExperimentData>(path), + ); + + return dataToExperiment(resultAPI.data); + } + + async editExperient( + uuid: string, + expriment: ExperimentEditInput, + ): Promise<Experiment> { + const path = this.options.baseurl + `experiments/${uuid}`; + + const resultAPI = await firstValueFrom( + this.httpService.patch<ExperimentData>(path, expriment), + ); + + return dataToExperiment(resultAPI.data); + } + + async removeExperiment(uuid: string): Promise<PartialExperiment> { + const path = this.options.baseurl + `experiments/${uuid}`; + + try { + await firstValueFrom(this.httpService.delete(path)); + return { + uuid: uuid, + }; + } catch (error) { + throw new BadRequestException(`${uuid} does not exists`); } -} \ No newline at end of file + } + + async getDomains(ids: string[]): Promise<Domain[]> { + const path = this.options.baseurl + 'pathologies'; + + try { + const data = await firstValueFrom( + this.httpService.get<Pathology[]>(path), + ); + + return ( + data?.data + .filter((data) => !ids || ids.length == 0 || ids.includes(data.code)) + .map((data): Domain => { + const groups = this.flattenGroups(data.metadataHierarchy); + + return { + id: data.code, + label: data.label, + groups: groups, + rootGroup: dataToGroup(data.metadataHierarchy), + datasets: data.datasets ? data.datasets.map(dataToCategory) : [], + variables: data.metadataHierarchy + ? this.flattenVariables(data.metadataHierarchy, groups) + : [], + }; + }) ?? [] + ); + } catch (error) { + throw new HttpException( + `Error in exareme engine : '${error.response.data['message']}'`, + error.response.data['statusCode'] ?? HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + getActiveUser(): Observable<string> { + const path = this.options.baseurl + 'activeUser'; + + return this.httpService + .get<string>(path) + .pipe(map((response) => response.data)); + } + + editActiveUser(): Observable<string> { + const path = this.options.baseurl + 'activeUser/agreeNDA'; + + return this.httpService + .post<string>(path, this.req.body) + .pipe(map((response) => response.data)); + } + + getExperimentREST(uuid: string): Observable<string> { + const path = this.options.baseurl + `experiments/${uuid}`; + + return this.httpService + .get<string>(path) + .pipe(map((response) => response.data)); + } + + deleteExperiment(uuid: string): Observable<string> { + const path = this.options.baseurl + `experiments/${uuid}`; + + return this.httpService.delete(path).pipe(map((response) => response.data)); + } + + editExperimentREST(uuid: string): Observable<string> { + const path = this.options.baseurl + `experiments/${uuid}`; + + return this.httpService + .patch(path, this.req.body) + .pipe(map((response) => response.data)); + } + + startExperimentTransient(): Observable<string> { + const path = this.options.baseurl + 'experiments/transient'; + + return this.httpService + .post(path, this.req.body) + .pipe(map((response) => response.data)); + } + + startExperiment(): Observable<string> { + const path = this.options.baseurl + 'experiments'; + + return this.httpService + .post(path, this.req.body) + .pipe(map((response) => response.data)); + } + + getExperiments(): Observable<string> { + const path = this.options.baseurl + 'experiments'; + + return this.httpService + .get<string>(path, { params: this.req.query }) + .pipe(map((response) => response.data)); + } + + getAlgorithmsREST(): Observable<string> { + const path = this.options.baseurl + 'algorithms'; + + return this.httpService + .get<string>(path, { params: this.req.query }) + .pipe(map((response) => response.data)); + } + + // UTILITIES + private flattenGroups = (data: Hierarchy): Group[] => { + let groups: Group[] = [dataToGroup(data)]; + + if (data.groups) { + groups = groups.concat(data.groups.flatMap(this.flattenGroups)); + } + + return groups; + }; + + private flattenVariables = (data: Hierarchy, groups: Group[]): Variable[] => { + const group = groups.find((group) => group.id == data.code); + let variables = data.variables ? data.variables.map(dataToVariable) : []; + + variables.forEach((variable) => (variable.groups = group ? [group] : [])); + + if (data.groups) { + variables = variables.concat( + data.groups.flatMap((hierarchy) => + this.flattenVariables(hierarchy, groups), + ), + ); + } + + return variables; + }; +} diff --git a/api/src/engine/connectors/exareme/transformations.ts b/api/src/engine/connectors/exareme/transformations.ts new file mode 100644 index 0000000000000000000000000000000000000000..a57e677bf1e341a527f2c98c105376135c6a4339 --- /dev/null +++ b/api/src/engine/connectors/exareme/transformations.ts @@ -0,0 +1,139 @@ +// This file contains all transformation queries for JSONata +// see : https://docs.jsonata.org/ + +import * as jsonata from 'jsonata'; // old import style needed due to 'export = jsonata' + +export const transformToAlgorithms = jsonata(` +( + $params := ["y", "pathology", "dataset", "filter"]; + + $toArray := function($x) { $type($x) = 'array' ? $x : [$x]}; + + *.{ + 'name': name, + 'label': label, + 'description': desc, + 'parameters': $toArray(parameters[$not(name in $params)].{ + 'name': name, + 'description': desc, + 'label': label, + 'type': valueType, + 'defaultValue': defaultValue, + 'isMultiple': $boolean(valueMultiple), + 'isRequired': $boolean(valueNotBlank), + 'min': valueMin, + 'max': valueMax + }) +} +) +`); + +export const transformToExperiment = jsonata(` +( + $params := ["y", "pathology", "dataset", "filter"]; + + { + "name": name, + "uuid": uuid, + "author": createdBy, + "viewed": viewed, + "status": status, + "createdAt": created, + "finishedAt": finished, + "shared": shared, + "updateAt": updated, + "domains": algorithm.parameters[name = "pathology"].value, + "variables": $split(algorithm.parameters[name = "y"].value, ','), + "filter": algorithm.parameters[name = "filter"].value, + "datasets": $split(algorithm.parameters[name = "dataset"].value, ','), + "algorithm": { + "name": algorithm.name, + "parameters" : + algorithm.parameters[$not(name in $params)].({ + "name": name, + "label": label, + "value": value + }) + } + } +) +`); + +const headerDescriptivie = ` +$fnum := function($x) { $type($x) = 'number' ? $round($number($x),3) : $x }; + +$e := function($x, $r) {($x != null) ? $fnum($x) : ($r ? $r : '')}; + +$fn := function($o, $prefix) { + $type($o) = 'object' ? + $each($o, function($v, $k) {( + $type($v) = 'object' ? { $k: $v.count & ' (' & $v.percentage & '%)' } : { + $k: $v + } + )}) ~> $merge() + : {} +};`; + +export const descriptiveModelToTables = jsonata(` +( + ${headerDescriptivie} + + $vars := $count($keys(data.model.*.data))-1; + $varNames := $keys(data.model.*.data); + $model := data.model; + + [[0..$vars].( + $i := $; + $varName := $varNames[$i]; + $ks := $keys($model.*.data.*[$i][$type($) = 'object']); + { + 'name': $varName, + 'headers': $append("", $keys($$.data.model)).{ + 'name': $, + 'type': 'string' + }, + 'data': [ + [$varName, $model.*.($e(num_total))], + ['Datapoints', $model.*.($e(num_datapoints))], + ['Nulls', $model.*.($e(num_nulls))], + ($lookup($model.*.data, $varName).($fn($)) ~> $reduce(function($a, $b) { + $map($ks, function($k) {( + { + $k : [$e($lookup($a,$k), "No data"), $e($lookup($b,$k), "No data")] + } + )}) ~> $merge() + })).$each(function($v, $k) {$append($k,$v)})[] + ] + } + )] +)`); + +export const descriptiveSingleToTables = jsonata(` +( + ${headerDescriptivie} + + data.[ + $.single.*@$p#$i.( + $ks := $keys($p.*.data[$type($) = 'object']); + { + 'name': $keys(%)[$i], + 'headers': $append("", $keys(*)).{ + 'name': $, + 'type': 'string' + }, + 'data' : [ + [$keys(%)[$i], $p.*.($e(num_total))], + ['Datapoints', $p.*.($e(num_datapoints))], + ['Nulls', $p.*.($e(num_nulls))], + ($p.*.data.($fn($)) ~> $reduce(function($a, $b) { + $map($ks, function($k) {( + { + $k : [$e($lookup($a,$k), "No data"), $e($lookup($b,$k), "No data")] + } + )}) ~> $merge() + })).$each(function($v, $k) {$append($k,$v)})[] + ] + }) + ] +) +`); diff --git a/api/src/engine/connectors/local/main.connector.ts b/api/src/engine/connectors/local/main.connector.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb472d927f571c9553c0115624a9981f2be653c6 --- /dev/null +++ b/api/src/engine/connectors/local/main.connector.ts @@ -0,0 +1,114 @@ +import { Observable } from 'rxjs'; +import { IEngineService } from 'src/engine/engine.interfaces'; +import { Domain } from 'src/engine/models/domain.model'; +import { ExperimentCreateInput } from 'src/engine/models/experiment/input/experiment-create.input'; +import { + Experiment, + PartialExperiment, +} from 'src/engine/models/experiment/experiment.model'; +import { ListExperiments } from 'src/engine/models/experiment/list-experiments.model'; +import { ExperimentEditInput } from 'src/engine/models/experiment/input/experiment-edit.input'; +import { Algorithm } from 'src/engine/models/experiment/algorithm.model'; + +export default class LocalService implements IEngineService { + logout(): void { + throw new Error('Method not implemented.'); + } + + getAlgorithms(): Algorithm[] | Promise<Algorithm[]> { + throw new Error('Method not implemented.'); + } + + createExperiment( + data: ExperimentCreateInput, + isTransient: boolean, + ): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + listExperiments( + page: number, + name: string, + ): ListExperiments | Promise<ListExperiments> { + throw new Error('Method not implemented.'); + } + + getExperiment(uuid: string): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + removeExperiment( + uuid: string, + ): PartialExperiment | Promise<PartialExperiment> { + throw new Error('Method not implemented.'); + } + + editExperient( + uuid: string, + expriment: ExperimentEditInput, + ): Experiment | Promise<Experiment> { + throw new Error('Method not implemented.'); + } + + getDomains(): Domain[] { + return [ + { + id: 'Dummy', + label: 'Dummy', + datasets: [{ id: 'DummyDataset', label: 'DummyDataset' }], + groups: [ + { + id: 'DummyGroup', + variables: ['DummyVar'], + groups: [], + }, + ], + rootGroup: { id: 'DummyGroup' }, + variables: [{ id: 'DummyVar', type: 'string' }], + }, + ]; + } + + getActiveUser(): string { + const dummyUser = { + username: 'anonymous', + subjectId: 'anonymousId', + fullname: 'anonymous', + email: 'anonymous@anonymous.com', + agreeNDA: true, + }; + return JSON.stringify(dummyUser); + } + + editActiveUser(): Observable<string> { + throw new Error('Method not implemented.'); + } + + getExperimentREST(): Observable<string> { + throw new Error('Method not implemented.'); + } + + deleteExperiment(): Observable<string> { + throw new Error('Method not implemented.'); + } + + editExperimentREST(): Observable<string> { + throw new Error('Method not implemented.'); + } + + startExperimentTransient(): Observable<string> { + throw new Error('Method not implemented.'); + } + + startExperiment(): Observable<string> { + throw new Error('Method not implemented.'); + } + + getExperiments(): string { + return '[]'; + } + + getAlgorithmsREST(): string { + return '[]'; + } +} diff --git a/api/src/engine/engine.constants.ts b/api/src/engine/engine.constants.ts index 8cd04e3e70f4401d1b73841cf79c4e57ba138dd4..e8fe57799577d257d79d8eea188da5f1bed75417 100644 --- a/api/src/engine/engine.constants.ts +++ b/api/src/engine/engine.constants.ts @@ -1,2 +1,2 @@ -export const ENGINE_MODULE_OPTIONS = "EngineModuleOption"; -export const ENGINE_SERVICE = "EngineService" \ No newline at end of file +export const ENGINE_MODULE_OPTIONS = 'EngineModuleOption'; +export const ENGINE_SERVICE = 'EngineService'; diff --git a/api/src/engine/engine.controller.ts b/api/src/engine/engine.controller.ts index 503edea819501aa28b12574822a5182ae0b86718..d7f34d493e03ae0f08401baf9aab1b888851b9f9 100644 --- a/api/src/engine/engine.controller.ts +++ b/api/src/engine/engine.controller.ts @@ -1,14 +1,72 @@ -import { HttpService } from '@nestjs/axios'; -import { Controller, Get, Inject } from '@nestjs/common'; +import { + Controller, + Delete, + Get, + Inject, + Param, + Patch, + Post, + UseInterceptors, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; import { ENGINE_SERVICE } from './engine.constants'; -import { IEngineService } from './engine.interface'; +import { IEngineService } from './engine.interfaces'; +import { HeadersInterceptor } from './interceptors/headers.interceptor'; +@UseInterceptors(HeadersInterceptor) @Controller() export class EngineController { - constructor(@Inject(ENGINE_SERVICE) private readonly engineService: IEngineService, private readonly httpService: HttpService) { } + constructor( + @Inject(ENGINE_SERVICE) private readonly engineService: IEngineService, + ) {} - @Get("/test") - getTest(): string { - return this.engineService.demo(); + @Get('/algorithms') + getAlgorithms(): Observable<string> | string { + return this.engineService.getAlgorithmsREST(); + } + + @Get('/experiments') + getExperiments(): Observable<string> | string { + return this.engineService.getExperiments(); + } + + @Get('/experiments/:uuid') + getExperiment(@Param('uuid') uuid: string): Observable<string> | string { + return this.engineService.getExperimentREST(uuid); + } + + @Delete('/experiments/:uuid') + deleteExperiment(@Param('uuid') uuid: string): Observable<string> | string { + return this.engineService.deleteExperiment(uuid); + } + + @Patch('/experiments/:uuid') + editExperiment(@Param('uuid') uuid: string): Observable<string> | string { + return this.engineService.editExperimentREST(uuid); + } + + @Post('experiments/transient') + startExperimentTransient(): Observable<string> | string { + return this.engineService.startExperimentTransient(); + } + + @Post('experiments') + startExperiment(): Observable<string> | string { + return this.engineService.startExperiment(); + } + + @Get('activeUser') + getActiveUser(): Observable<string> | string { + return this.engineService.getActiveUser(); + } + + @Post('activeUser/agreeNDA') + agreeNDA(): Observable<string> | string { + return this.engineService.editActiveUser(); + } + + @Get('logout') + logout(): void { + this.engineService.logout(); } } diff --git a/api/src/engine/engine.interface.ts b/api/src/engine/engine.interface.ts deleted file mode 100644 index 073b05a3f933ea9d4b9ab1d26ea840b7f31e5018..0000000000000000000000000000000000000000 --- a/api/src/engine/engine.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AxiosResponse } from "axios"; -import { Observable } from "rxjs"; - -export interface IEngineOptions { - type: string; -} - -export interface IEngineService { - demo(): string; -} \ No newline at end of file diff --git a/api/src/engine/engine.interfaces.ts b/api/src/engine/engine.interfaces.ts new file mode 100644 index 0000000000000000000000000000000000000000..29e57f161a8dc2a69c747d415fa529b4c986002a --- /dev/null +++ b/api/src/engine/engine.interfaces.ts @@ -0,0 +1,64 @@ +import { Observable } from 'rxjs'; +import { Domain } from './models/domain.model'; +import { Algorithm } from './models/experiment/algorithm.model'; +import { + Experiment, + PartialExperiment, +} from './models/experiment/experiment.model'; +import { ExperimentCreateInput } from './models/experiment/input/experiment-create.input'; +import { ExperimentEditInput } from './models/experiment/input/experiment-edit.input'; +import { ListExperiments } from './models/experiment/list-experiments.model'; + +export interface IEngineOptions { + type: string; + baseurl: string; +} + +export interface IEngineService { + //GraphQL + getDomains(ids: string[]): Domain[] | Promise<Domain[]>; + + createExperiment( + data: ExperimentCreateInput, + isTransient: boolean, + ): Promise<Experiment> | Experiment; + + listExperiments( + page: number, + name: string, + ): Promise<ListExperiments> | ListExperiments; + + getExperiment(uuid: string): Promise<Experiment> | Experiment; + + removeExperiment( + uuid: string, + ): Promise<PartialExperiment> | PartialExperiment; + + editExperient( + uuid: string, + expriment: ExperimentEditInput, + ): Promise<Experiment> | Experiment; + + getAlgorithms(): Promise<Algorithm[]> | Algorithm[]; + + // Standard REST API call + getAlgorithmsREST(): Observable<string> | string; + + getExperiments(): Observable<string> | string; + + getExperimentREST(uuid: string): Observable<string> | string; + + deleteExperiment(uuid: string): Observable<string> | string; + + editExperimentREST(uuid: string): Observable<string> | string; + + startExperimentTransient(): Observable<string> | string; + + startExperiment(): Observable<string> | string; + + getActiveUser(): Observable<string> | string; + + editActiveUser(): Observable<string> | string; + + logout(): void; +} diff --git a/api/src/engine/engine.module.ts b/api/src/engine/engine.module.ts index 2490e51b9327b2241cbd246206173a953d7a91a8..48442588a8d1a2c5120e99fa15d60e7da3c29739 100644 --- a/api/src/engine/engine.module.ts +++ b/api/src/engine/engine.module.ts @@ -1,15 +1,18 @@ import { HttpModule, HttpService } from '@nestjs/axios'; -import { DynamicModule, Global, Module } from '@nestjs/common'; +import { DynamicModule, Global, Logger, Module } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; import { GraphQLModule } from '@nestjs/graphql'; import { join } from 'path'; import { ENGINE_MODULE_OPTIONS, ENGINE_SERVICE } from './engine.constants'; import { EngineController } from './engine.controller'; -import { IEngineOptions, IEngineService } from './engine.interface'; +import { IEngineOptions, IEngineService } from './engine.interfaces'; import { EngineResolver } from './engine.resolver'; @Global() @Module({}) export class EngineModule { + private static readonly logger = new Logger(EngineModule.name); + static async forRootAsync(options: IEngineOptions): Promise<DynamicModule> { const optionsProvider = { provide: ENGINE_MODULE_OPTIONS, @@ -18,10 +21,10 @@ export class EngineModule { const engineProvider = { provide: ENGINE_SERVICE, - useFactory: async (httpService: HttpService) => { - return await this.createEngineConnection(options, httpService) + useFactory: async (httpService: HttpService, req: Request) => { + return await this.createEngineConnection(options, httpService, req); }, - inject: [HttpService] + inject: [HttpService, REQUEST], }; return { @@ -32,22 +35,29 @@ export class EngineModule { autoSchemaFile: join(process.cwd(), 'src/schema.gql'), }), ], - providers: [ - optionsProvider, - engineProvider, - EngineResolver - ], - controllers: [ - EngineController - ], + providers: [optionsProvider, engineProvider, EngineResolver], + controllers: [EngineController], exports: [optionsProvider, engineProvider], - } + }; } - private static async createEngineConnection(options: IEngineOptions, httpService: HttpService): Promise<IEngineService> { - let service = await import(`./connectors/${options.type}/main.connector`); + private static async createEngineConnection( + options: IEngineOptions, + httpService: HttpService, + req: Request, + ): Promise<IEngineService> { + try { + const service = await import( + `./connectors/${options.type}/main.connector` + ); + const engine = new service.default(options, httpService, req); - return new service.default(options, httpService); + return engine; + } catch { + this.logger.error( + `There is a problem with the connector '${options.type}'`, + ); + process.exit(); // We can't continue without an engine, shutdown the process... + } } - } diff --git a/api/src/engine/engine.resolver.ts b/api/src/engine/engine.resolver.ts index 94f911825d5548eabea84382f7f5727e77e68092..6e23ba38ca26d1129e51abb7cbba8d311cad67b9 100644 --- a/api/src/engine/engine.resolver.ts +++ b/api/src/engine/engine.resolver.ts @@ -1,14 +1,75 @@ -import { Inject } from '@nestjs/common'; -import { Query, Resolver } from '@nestjs/graphql'; +import { Inject, UseInterceptors } from '@nestjs/common'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { ENGINE_SERVICE } from './engine.constants'; -import { IEngineService } from './engine.interface'; +import { IEngineService } from './engine.interfaces'; +import { HeadersInterceptor } from './interceptors/headers.interceptor'; +import { Domain } from './models/domain.model'; +import { Algorithm } from './models/experiment/algorithm.model'; +import { + Experiment, + PartialExperiment, +} from './models/experiment/experiment.model'; +import { ExperimentCreateInput } from './models/experiment/input/experiment-create.input'; +import { ExperimentEditInput } from './models/experiment/input/experiment-edit.input'; +import { ListExperiments } from './models/experiment/list-experiments.model'; +@UseInterceptors(HeadersInterceptor) @Resolver() export class EngineResolver { - constructor(@Inject(ENGINE_SERVICE) private readonly engineService: IEngineService) { } + constructor( + @Inject(ENGINE_SERVICE) private readonly engineService: IEngineService, + ) {} - @Query(() => String) - async hello() { - return this.engineService.demo(); - } -} \ No newline at end of file + @Query(() => [Domain]) + async domains( + @Args('ids', { nullable: true, type: () => [String], defaultValue: [] }) + ids: string[], + ) { + return this.engineService.getDomains(ids); + } + + @Query(() => ListExperiments) + async experiments( + @Args('page', { nullable: true, defaultValue: 0 }) page: number, + @Args('name', { nullable: true, defaultValue: '' }) name: string, + ) { + return this.engineService.listExperiments(page, name); + } + + @Query(() => Experiment) + async expriment(@Args('uuid') uuid: string) { + return this.engineService.getExperiment(uuid); + } + + @Query(() => [Algorithm]) + async algorithms() { + return this.engineService.getAlgorithms(); + } + + @Mutation(() => Experiment) + async createExperiment( + @Args('data') experimentCreateInput: ExperimentCreateInput, + @Args('isTransient', { nullable: true, defaultValue: false }) + isTransient: boolean, + ) { + return this.engineService.createExperiment( + experimentCreateInput, + isTransient, + ); + } + + @Mutation(() => Experiment) + async editExperiment( + @Args('uuid') uuid: string, + @Args('data') experiment: ExperimentEditInput, + ) { + return this.engineService.editExperient(uuid, experiment); + } + + @Mutation(() => PartialExperiment) + async removeExperiment( + @Args('uuid') uuid: string, + ): Promise<PartialExperiment> { + return this.engineService.removeExperiment(uuid); + } +} diff --git a/api/src/engine/interceptors/headers.interceptor.ts b/api/src/engine/interceptors/headers.interceptor.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b28e4953456de5d9bc66a390c9cf452cf45306f --- /dev/null +++ b/api/src/engine/interceptors/headers.interceptor.ts @@ -0,0 +1,45 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable, NestInterceptor, CallHandler } from '@nestjs/common'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { IncomingMessage } from 'http'; +import { Observable, tap } from 'rxjs'; + +@Injectable() +export class HeadersInterceptor implements NestInterceptor { + constructor(private httpService: HttpService) {} + + intercept(context: GqlExecutionContext, next: CallHandler): Observable<any> { + // cleaner : add only the auth header (should find the name) + + const keys = ['cookie', 'x-xsrf-token']; + let headers = {}; + + switch (context.getType()) { + case 'http': { + const ctx = context.switchToHttp(); + const request = ctx.getRequest<Request>(); + headers = request.headers; + break; + } + case 'graphql': { + const ctx = GqlExecutionContext.create(context); + const req: IncomingMessage = ctx.getContext().req; + headers = req.headers; + break; + } + } + + Object.keys(headers) + .filter((key) => keys.includes(key)) + .map((key) => key.toLowerCase()) + .forEach((key) => { + this.httpService.axiosRef.defaults.headers.common[key] = headers[key]; + }); + + return next.handle().pipe( + tap(() => { + this.httpService.axiosRef.defaults.headers.common = {}; //cleaning request + }), + ); + } +} diff --git a/api/src/engine/models/category.model.ts b/api/src/engine/models/category.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..921ebfce8d518a66a484bf44d04cf330b8091915 --- /dev/null +++ b/api/src/engine/models/category.model.ts @@ -0,0 +1,5 @@ +import { ObjectType } from '@nestjs/graphql'; +import { Entity } from './entity.model'; + +@ObjectType() +export class Category extends Entity {} diff --git a/api/src/engine/models/domain.model.ts b/api/src/engine/models/domain.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5b8def42a318613b348040b2eccb3ea67a7aa29 --- /dev/null +++ b/api/src/engine/models/domain.model.ts @@ -0,0 +1,23 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { Category } from './category.model'; +import { Entity } from './entity.model'; +import { Group } from './group.model'; +import { Variable } from './variable.model'; + +@ObjectType() +export class Domain extends Entity { + @Field({ nullable: true }) + description?: string; + + @Field(() => [Group]) + groups: Group[]; + + @Field(() => [Variable]) + variables: Variable[]; + + @Field(() => [Category]) + datasets: Category[]; + + @Field(() => Group) + rootGroup: Group; +} diff --git a/api/src/engine/models/entity.model.ts b/api/src/engine/models/entity.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..32183fd7e970410a1ee3f479cef4d691d56fb7a7 --- /dev/null +++ b/api/src/engine/models/entity.model.ts @@ -0,0 +1,11 @@ +import { Field, InputType, ObjectType } from '@nestjs/graphql'; + +@InputType() +@ObjectType() +export class Entity { + @Field() + id: string; + + @Field({ nullable: true }) + label?: string; +} diff --git a/api/src/engine/models/experiment/algorithm-parameter.model.ts b/api/src/engine/models/experiment/algorithm-parameter.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5db6bfc11634ae3ec054c4c6eef9a5441f74931 --- /dev/null +++ b/api/src/engine/models/experiment/algorithm-parameter.model.ts @@ -0,0 +1,34 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class AlgorithmParameter { + @Field() + name: string; + + @Field(() => [String], { nullable: true }) + value?: string[]; + + @Field({ nullable: true }) + label?: string; + + @Field({ nullable: true }) + description?: string; + + @Field({ nullable: true }) + defaultValue?: string; + + @Field({ defaultValue: false, nullable: true }) + isMultiple?: boolean; + + @Field({ defaultValue: false, nullable: true }) + isRequired?: boolean; + + @Field({ nullable: true }) + min?: string; + + @Field({ nullable: true }) + max?: string; + + @Field({ nullable: true }) + type?: string; +} diff --git a/api/src/engine/models/experiment/algorithm.model.ts b/api/src/engine/models/experiment/algorithm.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec26d747fc8019a29822bf673aa82b29162f5757 --- /dev/null +++ b/api/src/engine/models/experiment/algorithm.model.ts @@ -0,0 +1,20 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { AlgorithmParameter } from './algorithm-parameter.model'; + +@ObjectType() +export class Algorithm { + @Field() + name: string; + + @Field(() => [AlgorithmParameter], { nullable: true, defaultValue: [] }) + parameters?: AlgorithmParameter[]; + + @Field({ nullable: true }) + label?: string; + + @Field({ nullable: true }) + type?: string; + + @Field({ nullable: true }) + description?: string; +} diff --git a/api/src/engine/models/experiment/experiment.model.ts b/api/src/engine/models/experiment/experiment.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..06e28b4b2e1c93e8dac7c6e7dc828c706f370c8a --- /dev/null +++ b/api/src/engine/models/experiment/experiment.model.ts @@ -0,0 +1,54 @@ +import { Field, ObjectType, PartialType } from '@nestjs/graphql'; +import { ResultUnion } from '../result/common/result-union.model'; +import { Algorithm } from './algorithm.model'; + +@ObjectType() +export class Experiment { + @Field({ nullable: true }) + uuid?: string; + + @Field({ nullable: true, defaultValue: '' }) + author?: string; + + @Field({ nullable: true }) + createdAt?: number; + + @Field({ nullable: true }) + updateAt?: number; + + @Field({ nullable: true }) + finishedAt?: number; + + @Field({ nullable: true, defaultValue: false }) + viewed?: boolean; + + @Field({ nullable: true }) + status?: string; + + @Field({ defaultValue: false }) + shared?: boolean; + + @Field(() => [ResultUnion], { nullable: true, defaultValue: [] }) + results?: Array<typeof ResultUnion>; + + @Field(() => [String]) + datasets: string[]; + + @Field(() => String, { nullable: true }) + filter?: string; + + @Field() + domain: string; + + @Field(() => [String]) + variables: string[]; + + @Field() + algorithm: Algorithm; + + @Field() + name: string; +} + +@ObjectType() +export class PartialExperiment extends PartialType(Experiment) {} diff --git a/api/src/engine/models/experiment/input/algorithm-parameter.input.ts b/api/src/engine/models/experiment/input/algorithm-parameter.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..8eb4cb26b5dade932440b6fef169275988d33b03 --- /dev/null +++ b/api/src/engine/models/experiment/input/algorithm-parameter.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class AlgorithmParamInput { + @Field() + name: string; + + @Field(() => [String]) + value: string[]; +} diff --git a/api/src/engine/models/experiment/input/algorithm.input.ts b/api/src/engine/models/experiment/input/algorithm.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..54d26b7686559c39b311cee5e5496bce530b0cf5 --- /dev/null +++ b/api/src/engine/models/experiment/input/algorithm.input.ts @@ -0,0 +1,14 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { AlgorithmParamInput } from './algorithm-parameter.input'; + +@InputType() +export class AlgorithmInput { + @Field() + name: string; + + @Field(() => [AlgorithmParamInput], { nullable: true, defaultValue: [] }) + parameters: AlgorithmParamInput[]; + + @Field() + type: string; +} diff --git a/api/src/engine/models/experiment/input/experiment-create.input.ts b/api/src/engine/models/experiment/input/experiment-create.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0b57cea85b17b0b49d4cbdf7b6cc12f21b71688 --- /dev/null +++ b/api/src/engine/models/experiment/input/experiment-create.input.ts @@ -0,0 +1,38 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { AlgorithmInput } from './algorithm.input'; + +@InputType() +export class FormulaTransformation { + @Field() + name: string; + + @Field() + operation: string; +} + +@InputType() +export class ExperimentCreateInput { + @Field(() => [String]) + datasets: string[]; + + @Field(() => String, { nullable: true }) + filter: string; + + @Field() + domain: string; + + @Field(() => [String]) + variables: string[]; + + @Field() + algorithm: AlgorithmInput; + + @Field() + name: string; + + @Field(() => [FormulaTransformation], { nullable: true }) + transformations: FormulaTransformation[]; + + @Field(() => [[String]], { nullable: true }) + interactions: string[][]; +} diff --git a/api/src/engine/models/experiment/input/experiment-edit.input.ts b/api/src/engine/models/experiment/input/experiment-edit.input.ts new file mode 100644 index 0000000000000000000000000000000000000000..d37deb1e8d008d746fda7214bb6890142b74a12a --- /dev/null +++ b/api/src/engine/models/experiment/input/experiment-edit.input.ts @@ -0,0 +1,10 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class ExperimentEditInput { + @Field({ nullable: true }) + name?: string; + + @Field({ nullable: true }) + viewed?: boolean; +} diff --git a/api/src/engine/models/experiment/list-experiments.model.ts b/api/src/engine/models/experiment/list-experiments.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f8592e52f1e3d85946d373a73b4edd00ac318f5 --- /dev/null +++ b/api/src/engine/models/experiment/list-experiments.model.ts @@ -0,0 +1,17 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { Experiment } from './experiment.model'; + +@ObjectType() +export class ListExperiments { + @Field({ nullable: true, defaultValue: 0 }) + currentPage?: number; + + @Field({ nullable: true }) + totalPages?: number; + + @Field({ nullable: true }) + totalExperiments?: number; + + @Field(() => [Experiment]) + experiments: Experiment[]; +} diff --git a/api/src/engine/models/group.model.ts b/api/src/engine/models/group.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a23f1983398f81eeca84731c3d8cfe4b92b599a --- /dev/null +++ b/api/src/engine/models/group.model.ts @@ -0,0 +1,18 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { Entity } from './entity.model'; + +@ObjectType() +export class Group extends Entity { + @Field({ nullable: true }) + description?: string; + + @Field(() => [String], { defaultValue: [], nullable: true }) + groups?: string[]; + + @Field(() => [String], { + description: "List of variable's ids", + defaultValue: [], + nullable: true, + }) + variables?: string[]; +} diff --git a/api/src/engine/models/result/common/chart-axis.model.ts b/api/src/engine/models/result/common/chart-axis.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..16fc1f68a10e379b0bf4e45225c7912710d1d34d --- /dev/null +++ b/api/src/engine/models/result/common/chart-axis.model.ts @@ -0,0 +1,10 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class ChartAxis { + @Field({ nullable: true, defaultValue: '' }) + label?: string; + + @Field(() => [String], { nullable: true, defaultValue: [] }) + categories?: string[]; +} diff --git a/api/src/engine/models/result/common/header.model.ts b/api/src/engine/models/result/common/header.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..27844983c4724df06cd8d8d79caf83ae4e00a6de --- /dev/null +++ b/api/src/engine/models/result/common/header.model.ts @@ -0,0 +1,10 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class Header { + @Field() + name: string; + + @Field() + type: 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 new file mode 100644 index 0000000000000000000000000000000000000000..2f56bb4d1f6ef577e9e836972f45dd59ede7fc28 --- /dev/null +++ b/api/src/engine/models/result/common/result-union.model.ts @@ -0,0 +1,40 @@ +import { createUnionType } from '@nestjs/graphql'; +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'; + +export const ResultUnion = createUnionType({ + name: 'ResultUnion', + types: () => [ + TableResult, + RawResult, + GroupsResult, + HeatMapResult, + LineChartResult, + ], + resolveType(value) { + if (value.headers) { + return TableResult; + } + + if (value.rawdata) { + return RawResult; + } + + if (value.groups) { + return GroupsResult; + } + + if (value.matrix) { + return HeatMapResult; + } + + if (value.x) { + return LineChartResult; + } + + return null; + }, +}); diff --git a/api/src/engine/models/result/common/result.model.ts b/api/src/engine/models/result/common/result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5e059738c16bb8297558add9aa51e7bce9791e2 --- /dev/null +++ b/api/src/engine/models/result/common/result.model.ts @@ -0,0 +1,4 @@ +import { ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export abstract class Result {} diff --git a/api/src/engine/models/result/groups-result.model.ts b/api/src/engine/models/result/groups-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ea51cff97c998309df74518f1efee0efcb0c3bb --- /dev/null +++ b/api/src/engine/models/result/groups-result.model.ts @@ -0,0 +1,25 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { ResultUnion } from './common/result-union.model'; +import { Result } from './common/result.model'; + +@ObjectType() +export class GroupResult { + public constructor(init?: Partial<GroupResult>) { + Object.assign(this, init); + } + + @Field() + name: string; + + @Field({ nullable: true }) + description?: string; + + @Field(() => [ResultUnion]) + results: Array<typeof ResultUnion>; +} + +@ObjectType() +export class GroupsResult extends Result { + @Field(() => [GroupResult]) + groups: GroupResult[]; +} diff --git a/api/src/engine/models/result/heat-map-result.model.ts b/api/src/engine/models/result/heat-map-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..267dc3c20201236e5d11ee1cbcbb6d6d89af15a7 --- /dev/null +++ b/api/src/engine/models/result/heat-map-result.model.ts @@ -0,0 +1,18 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { ChartAxis } from './common/chart-axis.model'; +import { Result } from './common/result.model'; + +@ObjectType() +export class HeatMapResult extends Result { + @Field() + name: string; + + @Field(() => [[Number]]) + matrix: number[][]; + + @Field(() => ChartAxis) + xAxis: ChartAxis; + + @Field(() => ChartAxis) + yAxis: ChartAxis; +} diff --git a/api/src/engine/models/result/line-chart-result.model.ts b/api/src/engine/models/result/line-chart-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..85ea4ff66e9780a1cb01de9ea68621b6486e9de5 --- /dev/null +++ b/api/src/engine/models/result/line-chart-result.model.ts @@ -0,0 +1,54 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { ChartAxis } from './common/chart-axis.model'; +import { Result } from './common/result.model'; + +export enum LineType { + NORMAL, + DASHED, +} + +registerEnumType(LineType, { + name: 'LineType', +}); + +@ObjectType() +export class ExtraLineInfo { + @Field() + label: string; + + @Field(() => [String]) + values: string[]; +} + +@ObjectType() +export class LineResult { + @Field() + label: string; + + @Field(() => [Number]) + x: number[]; + + @Field(() => [Number]) + y: number[]; + + @Field(() => [ExtraLineInfo], { nullable: true, defaultValue: [] }) + extraLineInfos?: ExtraLineInfo[]; + + @Field(() => LineType, { nullable: true, defaultValue: LineType.NORMAL }) + type?: LineType; +} + +@ObjectType() +export class LineChartResult extends Result { + @Field() + name: string; + + @Field(() => ChartAxis, { nullable: true }) + xAxis?: ChartAxis; + + @Field(() => ChartAxis, { nullable: true }) + yAxis?: ChartAxis; + + @Field(() => [LineResult]) + lines: LineResult[]; +} diff --git a/api/src/engine/models/result/raw-result.model.ts b/api/src/engine/models/result/raw-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..853362293bbfe6da230e487f8ea139ba36a16f3b --- /dev/null +++ b/api/src/engine/models/result/raw-result.model.ts @@ -0,0 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import GraphQLJSON from 'graphql-type-json'; +import { Result } from './common/result.model'; + +// field name 'rawdata' was used instead of 'data' because of typing problem on union with same field name +// see :https://stackoverflow.com/questions/44170603/graphql-using-same-field-names-in-different-types-within-union + +@ObjectType() +export class RawResult extends Result { + @Field(() => GraphQLJSON) + rawdata: unknown; +} diff --git a/api/src/engine/models/result/table-result.model.ts b/api/src/engine/models/result/table-result.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..002d59c5b15ce974e56bc9a9b0d9272212961c31 --- /dev/null +++ b/api/src/engine/models/result/table-result.model.ts @@ -0,0 +1,15 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { Header } from './common/header.model'; +import { Result } from './common/result.model'; + +@ObjectType() +export class TableResult extends Result { + @Field() + name: string; + + @Field(() => [[String]]) + data: string[][]; + + @Field(() => [Header]) + headers: Header[]; +} diff --git a/api/src/engine/models/variable.model.ts b/api/src/engine/models/variable.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd54afed70a025ce6c5673bf0627a657d77926cb --- /dev/null +++ b/api/src/engine/models/variable.model.ts @@ -0,0 +1,19 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { Category } from './category.model'; +import { Entity } from './entity.model'; +import { Group } from './group.model'; + +@ObjectType() +export class Variable extends Entity { + @Field({ nullable: true }) + type?: string; + + @Field({ nullable: true }) + description?: string; + + @Field(() => [Category], { nullable: true, defaultValue: [] }) + enumerations?: Category[]; + + @Field(() => [Group], { nullable: true, defaultValue: [] }) + groups?: Group[]; +} diff --git a/api/src/main.ts b/api/src/main.ts index af521d9150d9eecb68faf0d83fa7fd789b91ce98..d6f85b9d78faf28723a2b38bda2452c0866a8af4 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -2,7 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './main/app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - await app.listen(3000); + const app = await NestFactory.create(AppModule, { cors: true }); + await app.listen(process.env.GATEWAY_PORT); } bootstrap(); diff --git a/api/src/main/app.controller.ts b/api/src/main/app.controller.ts index 60647307bfcb11d99e62b992889d745b34233fe2..cce879ee622146012901c9adb47ef40c0fd3a555 100644 --- a/api/src/main/app.controller.ts +++ b/api/src/main/app.controller.ts @@ -1,10 +1,9 @@ -import { HttpService } from '@nestjs/axios'; import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { - constructor(private readonly appService: AppService) { } + constructor(private readonly appService: AppService) {} @Get() getHello(): string { diff --git a/api/src/main/app.module.ts b/api/src/main/app.module.ts index 302746526a7ea75609e579f01306b0e944413aa5..7638ce2f6f28ce28f57f57974833ba8207669bba 100644 --- a/api/src/main/app.module.ts +++ b/api/src/main/app.module.ts @@ -1,14 +1,21 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { EngineModule } from 'src/engine/engine.module'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env', '.env.defaults'], + }), EngineModule.forRootAsync({ - type: process.env.ENGINE_TYPE || "exareme" - })], + type: process.env.ENGINE_TYPE, + baseurl: process.env.ENGINE_BASE_URL, + }), + ], controllers: [AppController], providers: [AppService], }) -export class AppModule { } +export class AppModule {} diff --git a/api/src/schema.gql b/api/src/schema.gql index 12b643d401b9084f455a4b8f12a9d2ecd4e013d8..1d30677261172d7d7fdc830d938d329d9b5f2d5d 100644 --- a/api/src/schema.gql +++ b/api/src/schema.gql @@ -2,6 +2,214 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +type Category { + id: String! + label: String +} + +type Group { + id: String! + label: String + description: String + groups: [String!] + + """List of variable's ids""" + variables: [String!] +} + +type Variable { + id: String! + label: String + type: String + description: String + enumerations: [Category!] + groups: [Group!] +} + +type Domain { + id: String! + label: String + description: String + groups: [Group!]! + variables: [Variable!]! + datasets: [Category!]! + rootGroup: Group! +} + +type AlgorithmParameter { + name: String! + value: [String!] + label: String + description: String + defaultValue: String + isMultiple: Boolean + isRequired: Boolean + min: String + max: String + type: String +} + +type Algorithm { + name: String! + parameters: [AlgorithmParameter!] + label: String + type: String + description: String +} + +type GroupResult { + name: String! + description: String + results: [ResultUnion!]! +} + +union ResultUnion = TableResult | RawResult | GroupsResult | HeatMapResult | LineChartResult + +type TableResult { + name: String! + data: [[String!]!]! + headers: [Header!]! +} + +type RawResult { + rawdata: JSON! +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type GroupsResult { + groups: [GroupResult!]! +} + +type HeatMapResult { + name: String! + matrix: [[Float!]!]! + xAxis: ChartAxis! + yAxis: ChartAxis! +} + +type LineChartResult { + name: String! + xAxis: ChartAxis + yAxis: ChartAxis + lines: [LineResult!]! +} + +type ChartAxis { + label: String + categories: [String!] +} + +type ExtraLineInfo { + label: String! + values: [String!]! +} + +type LineResult { + label: String! + x: [Float!]! + y: [Float!]! + extraLineInfos: [ExtraLineInfo!] + type: LineType +} + +enum LineType { + NORMAL + DASHED +} + +type Header { + name: String! + type: String! +} + +type Experiment { + uuid: String + author: String + createdAt: Float + updateAt: Float + finishedAt: Float + viewed: Boolean + status: String + shared: Boolean! + results: [ResultUnion!] + datasets: [String!]! + filter: String + domain: String! + variables: [String!]! + algorithm: Algorithm! + name: String! +} + +type PartialExperiment { + uuid: String + author: String + createdAt: Float + updateAt: Float + finishedAt: Float + viewed: Boolean + status: String + shared: Boolean + results: [ResultUnion!] + datasets: [String!] + filter: String + domain: String + variables: [String!] + algorithm: Algorithm + name: String +} + +type ListExperiments { + currentPage: Float + totalPages: Float + totalExperiments: Float + experiments: [Experiment!]! +} + type Query { - hello: String! + domains(ids: [String!] = []): [Domain!]! + experiments(name: String = "", page: Float = 0): ListExperiments! + expriment(uuid: String!): Experiment! + algorithms: [Algorithm!]! +} + +type Mutation { + createExperiment(isTransient: Boolean = false, data: ExperimentCreateInput!): Experiment! + editExperiment(data: ExperimentEditInput!, uuid: String!): Experiment! + removeExperiment(uuid: String!): PartialExperiment! +} + +input ExperimentCreateInput { + datasets: [String!]! + filter: String + domain: String! + variables: [String!]! + algorithm: AlgorithmInput! + name: String! + transformations: [FormulaTransformation!] + interactions: [[String!]!] +} + +input AlgorithmInput { + name: String! + parameters: [AlgorithmParamInput!] = [] + type: String! +} + +input AlgorithmParamInput { + name: String! + value: [String!]! +} + +input FormulaTransformation { + name: String! + operation: String! +} + +input ExperimentEditInput { + name: String + viewed: Boolean }