Skip to content

Commit

Permalink
Feat/#810 endpoints (#828)
Browse files Browse the repository at this point in the history
* add program exception model

* add route with controller

* add exception servicen and typings file

* exception db funcs

* 422 status code util

* exception util file

* clean up exception repo file

* fix req error handling

* fix record error handling for promise behaviour

* add swagger

* remove unused util file

* use enum for exception values

* fix up types

* add no emit typecheck command

* add no program exception record type guard

* rename req body check func

* change HTTP status code

Co-authored-by: Ciaran Schutte <[email protected]>
  • Loading branch information
ciaranschutte and Ciaran Schutte authored Nov 2, 2022
1 parent 1986ad2 commit d52aa28
Show file tree
Hide file tree
Showing 9 changed files with 406 additions and 54 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"perf-test": "npm run build && mocha --exit --timeout 60000 -r ts-node/register test/performance-test/**/*.spec.ts",
"build-ts": "tsc",
"watch-ts": "tsc -w",
"type-check": "tsc --noEmit",
"tslint": "tslint -c tslint.json -p tsconfig.json",
"debug": "npm run build && sleep 3 && npm run watch-debug",
"local": "npm run build && sleep 3 && npm run watch-local",
Expand Down
3 changes: 3 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import submissionRouter from './routes/submission';
import dictionaryRouter from './routes/dictionary';
import configRouter from './routes/config';
import icgcImport from './routes/icgc-import';
import exceptionRouter from './routes/exception';
import responseTime from 'response-time';
import morgan from 'morgan';

Expand Down Expand Up @@ -87,6 +88,8 @@ app.use('/submission/program/:programId/registration', registrationRouter);
app.use('/submission/program/:programId/clinical', submissionRouter);
app.use('/submission/icgc-import', icgcImport);

app.use('/exception/:programId', exceptionRouter);

app.use('/dictionary', dictionaryRouter);
app.use('/submission/schema', dictionaryRouter); // deprecated

Expand Down
78 changes: 78 additions & 0 deletions src/exception/exception-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2022 The Ontario Institute for Cancer Research. All rights reserved
*
* This program and the accompanying materials are made available under the terms of
* the GNU Affero General Public License v3.0. You should have received a copy of the
* GNU Affero General Public License along with this program.
* If not, see <http://www.gnu.org/licenses/>.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
* SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
* OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

import { Request, Response } from 'express';
import { HasFullWriteAccess } from '../decorators';
import { loggerFor } from '../logger';
import { ControllerUtils, TsvUtils } from '../utils';
import * as exceptionService from './exception-service';
import { isProgramExceptionRecord, isReadonlyArrayOf } from './types';

const L = loggerFor(__filename);

enum ProgramExceptionErrorMessage {
TSV_PARSING_FAILED = `This file is formatted incorrectly`,
}

class ExceptionController {
@HasFullWriteAccess()
async createProgramException(req: Request, res: Response) {
if (!requestContainsFile(req, res)) {
return false;
}

const programId = req.params.programId;
const file = req.file;

try {
const records = await TsvUtils.tsvToJson(file.path);
if (records.length === 0) {
throw new Error('TSV has no records!');
}

if (!isReadonlyArrayOf(records, isProgramExceptionRecord)) {
throw new Error('TSV is incorrectly structured');
}

const result = await exceptionService.operations.createProgramException({
programId,
records,
});

if (!result.successful) {
return res.status(422).send(result);
}
return res.status(201).send(result);
} catch (err) {
L.error(`Program Exception TSV_PARSING_FAILED`, err);
return ControllerUtils.unableToProcess(res, ProgramExceptionErrorMessage.TSV_PARSING_FAILED);
}
}
}

const requestContainsFile = (req: Request, res: Response): boolean => {
if (req.file === undefined || req.file.size <= 0) {
L.debug(`File missing`);
ControllerUtils.badRequest(res, `Program exception file upload required`);
return false;
}
return true;
};

export default new ExceptionController();
79 changes: 25 additions & 54 deletions src/exception/exception-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,102 +17,73 @@
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
import mongoose from 'mongoose';
import { F, MongooseUtils } from '../utils';
import { MongooseUtils } from '../utils';
import { loggerFor } from '../logger';
import { DeepReadonly } from 'deep-freeze';
import { ExceptionValue, ProgramException } from './types';

const L = loggerFor(__filename);

const ExceptionSchema = new mongoose.Schema({
schema: { type: String, required: true },
coreField: { type: String, required: true },
exceptionValue: { type: String, enum: ['Unknown', 'Missing', 'Not applicable'], required: true },
});

const ProgramExceptionSchema = new mongoose.Schema({
programName: { type: String, unique: true, required: true },
exceptions: { type: [ExceptionSchema], required: true },
const programExceptionSchema = new mongoose.Schema({
programId: String,
exceptions: [
{
schema: String,
coreField: String,
exceptionValue: { type: String, enum: Object.values(ExceptionValue) },
},
],
});

type ProgramExceptionDocument = mongoose.Document & ProgramException;

export const ProgramExceptionModel = mongoose.model<ProgramExceptionDocument>(
'ProgramException',
ProgramExceptionSchema,
programExceptionSchema,
);

interface ProgramExceptionItem {
schema: string;
coreField: string;
exceptionValue: string;
}

export interface ProgramException {
programName: string;
exceptions: ProgramExceptionItem[];
}

export interface ProgramExceptionRepository {
create(programException: ProgramException): Promise<DeepReadonly<ProgramException>>;
create(exception: ProgramException): Promise<DeepReadonly<ProgramException>>;
find(name: string): Promise<DeepReadonly<ProgramException> | undefined>;
replace(programException: ProgramException): Promise<DeepReadonly<ProgramException> | undefined>;
delete(name: string): Promise<void>;
}

export const programExceptionRepository: ProgramExceptionRepository = {
async create(req: ProgramException) {
L.debug(`Creating new program exception with: ${JSON.stringify(req)}`);
const programException = new ProgramExceptionModel(req);
async create(exception: ProgramException) {
L.debug(`Creating new program exception with: ${JSON.stringify(exception)}`);
try {
const doc = await programException.save();
const doc = await ProgramExceptionModel.findOneAndUpdate(
{ programId: exception.programId },
exception,
{ upsert: true, new: true, overwrite: true },
);
L.info(`doc created ${doc}`);
L.info('saved program exception');
return F(MongooseUtils.toPojo(doc) as ProgramException);
return MongooseUtils.toPojo(doc) as ProgramException;
} catch (e) {
L.error('failed to create program exception', e);
L.error('failed to create program exception: ', e);
throw new Error('failed to create program exception');
}
},

async find(name: string) {
L.debug(`finding program exception with name: ${JSON.stringify(name)}`);
try {
const doc = await ProgramExceptionModel.findOne({ programName: name });
const doc = await ProgramExceptionModel.findOne({ name });
if (doc) {
L.info(`doc found ${doc}`);
return F(MongooseUtils.toPojo(doc) as ProgramException);
return MongooseUtils.toPojo(doc) as ProgramException;
}
} catch (e) {
L.error('failed to find program exception', e);
throw new Error(`failed to find program exception with name: ${JSON.stringify(name)}`);
}
},

async replace(programException: ProgramException) {
L.debug(
`finding program exception with program name: ${JSON.stringify(
programException.programName,
)}`,
);
try {
const doc = await ProgramExceptionModel.replaceOne(
{ programName: programException.programName },
programException,
);
if (doc) {
L.info(`doc found ${doc}`);
return F(MongooseUtils.toPojo(doc) as ProgramException);
}
} catch (e) {
L.error('failed to find program exception', e);
throw new Error(`failed to update program exception with name: ${JSON.stringify(name)}`);
}
},

async delete(name: string) {
L.debug(`deleting program exception with program name: ${JSON.stringify(name)}`);
try {
await ProgramExceptionModel.findOneAndDelete({ programName: name });
await ProgramExceptionModel.findOneAndDelete({ name });
} catch (e) {
L.error('failed to find program exception', e);
throw new Error(`failed to delete program exception with name: ${JSON.stringify(name)}`);
Expand Down
158 changes: 158 additions & 0 deletions src/exception/exception-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2022 The Ontario Institute for Cancer Research. All rights reserved
*
* This program and the accompanying materials are made available under the terms of
* the GNU Affero General Public License v3.0. You should have received a copy of the
* GNU Affero General Public License along with this program.
* If not, see <http://www.gnu.org/licenses/>.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
* SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
* OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

import { DeepReadonly } from 'deep-freeze';
import * as dictionaryManager from '../dictionary/manager';
import { SchemaWithFields } from '../dictionary/manager';
import { programExceptionRepository } from './exception-repo';
import { ExceptionValue, ProgramException, ProgramExceptionRecord } from './types';

const recordsToException = (
programId: string,
records: ReadonlyArray<ProgramExceptionRecord>,
): ProgramException => ({
programId,
exceptions: records.map(r => ({
schema: r.schema,
coreField: r.requested_core_field,
exceptionValue: r.requested_exception_value,
})),
});

interface ProgramExceptionResult {
programException: undefined | DeepReadonly<ProgramException>;
errors: ValidationError[];
successful: boolean;
}
export namespace operations {
export const createProgramException = async ({
programId,
records,
}: {
programId: string;
records: ReadonlyArray<ProgramExceptionRecord>;
}): Promise<ProgramExceptionResult> => {
const errors = await validateExceptionRecords(programId, records);

if (errors.length > 0) {
return {
programException: undefined,
errors,
successful: false,
};
} else {
const exception = recordsToException(programId, records);

const result = await programExceptionRepository.create(exception);
return {
programException: result,
errors: [],
successful: true,
};
}
};
}

interface ValidationError {
message: string;
row: number;
}

const validateExceptionRecords = async (
programId: string,
records: ReadonlyArray<ProgramExceptionRecord>,
): Promise<ValidationError[]> => {
let errors: ValidationError[] = [];

for (const [idx, record] of records.entries()) {
const programErrors = checkProgramId(programId, record, idx);
const coreFieldErrors = await checkCoreField(record, idx);
const requestedValErrors = checkRequestedValue(record, idx);
errors = errors.concat(programErrors, coreFieldErrors, requestedValErrors);
}

return errors;
};

const createValidationError = (row: number, message: string) => ({
row: row + 1, // account for tsc header row
message,
});

const checkProgramId = (programId: string, record: ProgramExceptionRecord, idx: number) => {
if (programId !== record.program_name) {
return [
createValidationError(
idx,
`submitted program id of ${programId} does not match record program id of ${record.program_name}`,
),
];
}
return [];
};

const checkCoreField = async (record: ProgramExceptionRecord, idx: number) => {
const currentDictionary = await dictionaryManager.instance();

const requestedCoreField = record.requested_core_field;

if (requestedCoreField === undefined) {
return [createValidationError(idx, `requested_core_field field is not defined`)];
}

const fieldFilter = (field: { name: string; meta?: { core: boolean } }): boolean => {
return field.name === requestedCoreField && !!field.meta?.core;
};

const schemaFilter = (schema: SchemaWithFields): boolean => {
return schema.name === record.schema;
};

const existingDictionarySchema = await currentDictionary.getSchemasWithFields(
schemaFilter,
fieldFilter,
);

if (existingDictionarySchema[0] && existingDictionarySchema[0].fields.length === 0) {
return [
createValidationError(idx, `core field of ${record.requested_core_field} is not valid`),
];
}

return [];
};

const checkRequestedValue = (record: ProgramExceptionRecord, idx: number) => {
const validRequests = Object.values(ExceptionValue);
const requestedExceptionValue = record.requested_exception_value;

if (requestedExceptionValue === undefined) {
return [createValidationError(idx, `requested_exception_value field is not defined`)];
} else if (typeof requestedExceptionValue !== 'string') {
return [createValidationError(idx, `requested_exception_value is not a string`)];
} else if (!validRequests.includes(requestedExceptionValue)) {
return [
createValidationError(
idx,
`requested_exception_value is not valid. must be one of ${validRequests.join(', ')}`,
),
];
}
return [];
};
Loading

0 comments on commit d52aa28

Please sign in to comment.