Skip to content

Commit 81549e0

Browse files
Merge pull request #102 from bcgsc/feature/KBDEV-1505-upload-hgvs-route
Feature/kbdev 1505 upload hgvs route
2 parents 78aaaf9 + 90af44e commit 81549e0

8 files changed

Lines changed: 813 additions & 9 deletions

File tree

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const { connectDB } = require('./repo');
2020
const { getLoadVersion } = require('./repo/migrate/version');
2121
const { addExtensionRoutes } = require('./extensions');
2222
const { addSubgraphRoutes } = require('./routes/subgraphs');
23+
const { addHgvsUploadRoute } = require('./routes/upload');
2324
const { generateSwaggerSpec, registerSpecEndpoints } = require('./routes/openapi');
2425
const { addResourceRoutes } = require('./routes/resource');
2526
const { addPostToken } = require('./routes/auth');
@@ -195,6 +196,7 @@ class AppServer {
195196
}
196197
addExtensionRoutes(this);
197198
addSubgraphRoutes(this);
199+
addHgvsUploadRoute(this);
198200

199201
// catch any other errors
200202
addErrorRoute(this);

src/middleware/auth.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,30 @@ const checkSubgraphPermissions = async (req, res, next) => {
103103
return next();
104104
};
105105

106+
/**
107+
* Check that the user has permissions for the upload classes. Note that to do this, models and user
108+
* need to already be assigned to the request.
109+
*
110+
* @param {GraphKBRequest} req
111+
* @param {ClassDefinition} req.models an array of models for this request
112+
*
113+
*/
114+
const checkHgvsUploadPermissions = async (req, res, next) => {
115+
const { models, user } = req;
116+
117+
for (let i = 0; i < models.length; i++) {
118+
if (!checkUserAccessFor(user, models[i], PERMISSIONS.CREATE)) {
119+
return res.status(HTTP_STATUS.FORBIDDEN).json(new PermissionError(
120+
`The user ${user.name} does not have sufficient permissions on classes ${models[i]}`,
121+
));
122+
}
123+
}
124+
return next();
125+
};
126+
106127
module.exports = {
107128
checkClassPermissions,
129+
checkHgvsUploadPermissions,
108130
checkSubgraphPermissions,
109131
checkToken,
110132
checkUserAccessFor,
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/* eslint-disable no-return-await */
2+
const _ = require('lodash');
3+
const { parseVariant, jsonifyVariant } = require('@bcgsc-pori/graphkb-parser');
4+
const { util: { looksLikeRID } } = require('@bcgsc-pori/graphkb-schema');
5+
6+
const { create, select } = require('../commands');
7+
const { parse } = require('../query_builder/index');
8+
const {
9+
NoRecordFoundError,
10+
RecordConflictError,
11+
ValidationError,
12+
} = require('../error');
13+
14+
/**
15+
* Given a reference displayName from user input, format and
16+
* make sure any chromosomes is represented in a GraphKB-compatible way
17+
*
18+
* @param {string} ref the reference displayName
19+
* @returns {string} the formatted displayName
20+
*/
21+
const formatReferenceDisplayName = (ref) => {
22+
let match;
23+
24+
// Chromosome reference handling
25+
if ((match = ref.match(/^CHR\d{1,2}$/i))) return ref.toLowerCase();
26+
if ((match = ref.match(/^\d{1,2}$/i))) return `chr${ref}`;
27+
if ((match = ref.match(/^(MT|X|Y)$/i))) return ref.toLowerCase();
28+
if ((match = ref.match(/^CHR(MT|X|Y)$/i))) return match[1].toLowerCase();
29+
if ((match = ref.match(/^NC_0*(\d+)(?:\.\d+)?$/i))) return `chr${match[1]}`; // RefSeq NC_
30+
31+
// default, all caps for all non-chromosome references
32+
return ref.toUpperCase();
33+
};
34+
35+
/**
36+
* Given a Vocabulary name as variant's type, returns its RID
37+
*
38+
* @param {Object} session the DB session object
39+
* @param {string} opt.type the variant type name
40+
* @returns {string} the strignified record's RID
41+
*/
42+
const getTypeRID = async (session, type) => {
43+
const result = await select(
44+
session,
45+
parse({
46+
filters: { name: type.toLowerCase() },
47+
returnProperties: ['@rid'],
48+
target: 'Vocabulary',
49+
}),
50+
);
51+
52+
if (result.length === 0) {
53+
throw new NoRecordFoundError({
54+
message: `Vocabulary record ${type} dosen't exists. Vocabulary ontology must be uploaded first`,
55+
});
56+
}
57+
return String(result[0]['@rid']);
58+
};
59+
60+
/**
61+
* Given a Feature displayName (reference), returns its RID
62+
*
63+
* @param {Object} session the DB session object
64+
* @param {string} reference the reference displayName
65+
* @param {string} [opt.preferedRefSrcName] the name of the prefered reference source
66+
* @returns {string} the strignify record's RID
67+
*/
68+
const getReferenceRID = async (session, ref, { preferedRefSrcName } = {}) => {
69+
// Chromosome support
70+
const referenceDisplayName = formatReferenceDisplayName(ref);
71+
72+
// Feature
73+
// Lookup on displayName for easier version support
74+
const result = await select(
75+
session,
76+
parse({
77+
filters: { displayName: referenceDisplayName },
78+
returnProperties: ['@rid', 'source.name'],
79+
target: 'Feature',
80+
}),
81+
);
82+
83+
if (result.length === 0) {
84+
// TODO: Add support for new Feature upload
85+
throw new NoRecordFoundError({
86+
message: `Feature record ${referenceDisplayName} dosen't exists`,
87+
});
88+
}
89+
if (result.length > 1) {
90+
// pick the one from prefered source
91+
if (preferedRefSrcName) {
92+
for (const r of result) {
93+
if (r.source.name === preferedRefSrcName) {
94+
return String(r['@rid']);
95+
}
96+
}
97+
}
98+
}
99+
// defaults to 1st one
100+
return String(result[0]['@rid']);
101+
};
102+
103+
/**
104+
* Extract and format content from an HGVS-like variant notation.
105+
* Type and references are replaced by their RIDs.
106+
*
107+
* @param {Object} session the DB session object
108+
* @param {string} notation the variant notation
109+
* @param {string} [opt.preferedRefSrcName] the name of the prefered reference source
110+
* @returns {object} the upload content of a PositionalVariant
111+
*/
112+
const getContent = async (session, notation, { preferedRefSrcName } = {}) => {
113+
// Validation
114+
if (typeof notation !== 'string' || notation.trim().length === 0) {
115+
throw new ValidationError(
116+
{ message: 'notation is required and must be a non-empty string' },
117+
);
118+
}
119+
120+
// Notation parsing
121+
const content = jsonifyVariant(
122+
parseVariant(notation),
123+
);
124+
125+
// Replacing variant type by its RID
126+
content.type = await getTypeRID(session, content.type);
127+
128+
// Replacing variant references by their RIDs
129+
content.reference1 = await getReferenceRID(
130+
session,
131+
content.reference1,
132+
{ preferedRefSrcName },
133+
);
134+
135+
if (content.reference2) {
136+
content.reference2 = await getReferenceRID(
137+
session,
138+
content.reference2,
139+
{ preferedRefSrcName },
140+
);
141+
}
142+
143+
return content;
144+
};
145+
146+
/**
147+
* Given the upload content for a new PositionalVariant,
148+
* get the filters object for a select command
149+
*
150+
* @param {Object} content initially used to attempt creating a new PositionalVariant
151+
* @returns {object} the filters object
152+
*/
153+
const positionalVariantQueryFilters = (content) => ({
154+
AND: [
155+
'break1Repr',
156+
'break2Repr',
157+
'reference1',
158+
'reference2',
159+
'refSeq',
160+
'truncation',
161+
'type',
162+
'untemplatedSeq',
163+
'untemplatedSeqSize',
164+
]
165+
.filter((prop) => prop in content)
166+
.map((prop) => ({ [prop]: content[prop] })),
167+
});
168+
169+
/**
170+
* Upload a new PositionalVariant based on content
171+
* Will attempt to replace type and references by RIDs if needed
172+
* Existing record can optionally (default) be fetched and returned
173+
*
174+
* @param {Object} session the DB session object
175+
* @param {Object} user the user object
176+
* @param {Object} content the content's payload
177+
* @param {boolean} [opt.existsOk=true] to return existing record
178+
* @param {string} [opt.preferedRefSrcName] the name of the prefered reference source
179+
* @returns {[Record<string, any>, string]} Array containing:
180+
* - first element: the record object
181+
* - second element: the status name
182+
*/
183+
const uploadPositionalVariant = async (session, user, content, {
184+
existsOk = true,
185+
preferedRefSrcName,
186+
} = {}) => {
187+
const payload = _.cloneDeep(content);
188+
189+
// Make sure type is an RID
190+
if (!looksLikeRID(payload.type, true)) {
191+
payload.type = await getTypeRID(session, payload.type);
192+
}
193+
// Make sure references are RIDs
194+
if (!looksLikeRID(payload.reference1, true)) {
195+
payload.reference1 = await getReferenceRID(
196+
session,
197+
payload.reference1,
198+
{ preferedRefSrcName },
199+
);
200+
}
201+
if (payload.reference2 && !looksLikeRID(payload.reference2, true)) {
202+
payload.reference2 = await getReferenceRID(
203+
session,
204+
payload.reference2,
205+
{ preferedRefSrcName },
206+
);
207+
}
208+
209+
// Upload
210+
try {
211+
return [
212+
await create(session, {
213+
content: payload,
214+
modelName: 'PositionalVariant',
215+
user,
216+
}),
217+
'CREATED',
218+
];
219+
} catch (err) {
220+
// Return existing record
221+
if (err instanceof RecordConflictError && existsOk) {
222+
return [
223+
...await select(
224+
session,
225+
parse({
226+
filters: positionalVariantQueryFilters(payload),
227+
target: 'PositionalVariant',
228+
}),
229+
{ user },
230+
),
231+
'OK',
232+
];
233+
}
234+
throw err;
235+
}
236+
};
237+
238+
module.exports = {
239+
formatReferenceDisplayName,
240+
getContent,
241+
getReferenceRID,
242+
getTypeRID,
243+
positionalVariantQueryFilters,
244+
uploadPositionalVariant,
245+
};

src/routes/openapi/index.js

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@ const HTTP_STATUS = require('http-status-codes');
1212
const swaggerUi = require('swagger-ui-express');
1313

1414
const {
15-
POST_TOKEN,
16-
POST_PARSE,
15+
GET_LICENSE,
1716
GET_SCHEMA,
18-
GET_VERSION,
19-
QUERY,
2017
GET_STATS,
21-
POST_SIGN_LICENSE,
18+
GET_VERSION,
2219
POST_LICENSE,
23-
GET_LICENSE,
20+
POST_PARSE,
21+
POST_SIGN_LICENSE,
22+
POST_TOKEN,
23+
QUERY,
2424
SUBGRAPHS,
25+
UPLOAD_HGVS,
2526
} = require('./routes');
2627
const responses = require('./responses');
2728
const schemas = require('./schemas');
@@ -72,8 +73,7 @@ const STUB = {
7273
paths: {
7374
'/license': { get: GET_LICENSE, post: POST_LICENSE },
7475
'/license/sign': { post: POST_SIGN_LICENSE },
75-
'/parse': { post: POST_PARSE },
76-
'/query': { post: QUERY },
76+
// Metadata
7777
'/schema': { get: GET_SCHEMA },
7878
'/spec': {
7979
get: {
@@ -104,9 +104,13 @@ const STUB = {
104104
},
105105
},
106106
'/stats': { get: GET_STATS },
107+
'/version': { get: GET_VERSION },
108+
// General
109+
'/parse': { post: POST_PARSE },
110+
'/upload/hgvs': { post: UPLOAD_HGVS },
111+
'/query': { post: QUERY },
107112
'/subgraphs/{ontology}': { post: SUBGRAPHS },
108113
'/token': { post: POST_TOKEN },
109-
'/version': { get: GET_VERSION },
110114
},
111115
tags: [{
112116
description: 'routes dealing with app metadata', name: 'Metadata',

0 commit comments

Comments
 (0)