add GET routes for expression data (#1387)

* add GET routes for expression data

* fix comment typo
This commit is contained in:
Bruce Martin
2020-04-13 11:28:35 -07:00
committed by GitHub
parent 98c2a1359b
commit 136093d583
7 changed files with 323 additions and 139 deletions
+83 -92
View File
@@ -1,4 +1,3 @@
import _ from "lodash";
import * as globals from "../globals"; import * as globals from "../globals";
import { Universe, MatrixFBS } from "../util/stateManager"; import { Universe, MatrixFBS } from "../util/stateManager";
import * as Dataframe from "../util/dataframe"; import * as Dataframe from "../util/dataframe";
@@ -6,7 +5,7 @@ import {
catchErrorsWrap, catchErrorsWrap,
doJsonRequest, doJsonRequest,
doBinaryRequest, doBinaryRequest,
dispatchNetworkErrorMessageToUser dispatchNetworkErrorMessageToUser,
} from "../util/actionHelpers"; } from "../util/actionHelpers";
import { PromiseLimit } from "../util/promiseLimit"; import { PromiseLimit } from "../util/promiseLimit";
import { requestReembed, reembedResetWorldToUniverse } from "./reembed"; import { requestReembed, reembedResetWorldToUniverse } from "./reembed";
@@ -19,23 +18,23 @@ function obsAnnotationFetchAndLoad(dispatch, schema) {
const obsAnnotations = schema?.schema?.annotations?.obs ?? {}; const obsAnnotations = schema?.schema?.annotations?.obs ?? {};
const index = obsAnnotations.index ?? false; const index = obsAnnotations.index ?? false;
const columns = (obsAnnotations.columns ?? []).filter( const columns = (obsAnnotations.columns ?? []).filter(
col => col.name !== index (col) => col.name !== index
); );
const plimit = new PromiseLimit(4); const plimit = new PromiseLimit(5);
return Promise.all( return Promise.all(
columns.map(col => columns.map((col) =>
plimit.add(() => { plimit.add(() => {
const path = `annotations/obs?annotation-name=${encodeURIComponent( const path = `annotations/obs?annotation-name=${encodeURIComponent(
col.name col.name
)}`; )}`;
const url = `${globals.API.prefix}${globals.API.version}${path}`; const url = `${globals.API.prefix}${globals.API.version}${path}`;
return doBinaryRequest(url).then(buffer => { return doBinaryRequest(url).then((buffer) => {
const df = Universe.matrixFBSToDataframe(buffer); const df = Universe.matrixFBSToDataframe(buffer);
dispatch({ dispatch({
type: "universe: column load success", type: "universe: column load success",
dim: "obsAnnotations", dim: "obsAnnotations",
dataframe: df dataframe: df,
}); });
}); });
}) })
@@ -52,20 +51,22 @@ function varAnnotationFetchAndLoad(dispatch, schema) {
const names = index ? [index] : []; const names = index ? [index] : [];
return Promise.all( return Promise.all(
names names
.map(name => { .map((name) => {
const path = `annotations/var?annotation-name=${encodeURIComponent( const path = `annotations/var?annotation-name=${encodeURIComponent(
name name
)}`; )}`;
const url = `${globals.API.prefix}${globals.API.version}${path}`; const url = `${globals.API.prefix}${globals.API.version}${path}`;
return doBinaryRequest(url); return doBinaryRequest(url);
}) })
.map(rqst => rqst.then(buffer => Universe.matrixFBSToDataframe(buffer))) .map((rqst) =>
.map(resp => rqst.then((buffer) => Universe.matrixFBSToDataframe(buffer))
resp.then(df => )
.map((resp) =>
resp.then((df) =>
dispatch({ dispatch({
type: "universe: column load success", type: "universe: column load success",
dim: "varAnnotations", dim: "varAnnotations",
dataframe: df dataframe: df,
}) })
) )
) )
@@ -77,25 +78,25 @@ return promise fetching layout we need
*/ */
function layoutFetchAndLoad(dispatch, schema) { function layoutFetchAndLoad(dispatch, schema) {
const embeddings = schema?.schema?.layout?.obs ?? []; const embeddings = schema?.schema?.layout?.obs ?? [];
const embNames = embeddings.map(e => e.name); const embNames = embeddings.map((e) => e.name);
const baseURL = `${globals.API.prefix}${globals.API.version}layout/obs`; const baseURL = `${globals.API.prefix}${globals.API.version}layout/obs`;
const plimit = new PromiseLimit(4); const plimit = new PromiseLimit(5);
return Promise.all( return Promise.all(
embNames.map(e => embNames.map((e) =>
plimit.add(() => { plimit.add(() => {
const url = `${baseURL}?layout-name=${encodeURIComponent(e)}`; const url = `${baseURL}?layout-name=${encodeURIComponent(e)}`;
return doBinaryRequest(url).then(buffer => return doBinaryRequest(url).then((buffer) =>
Universe.matrixFBSToDataframe(buffer) Universe.matrixFBSToDataframe(buffer)
); );
}) })
) )
).then(dfs => { ).then((dfs) => {
const df = Dataframe.Dataframe.empty().withColsFromAll(dfs); const df = Dataframe.Dataframe.empty().withColsFromAll(dfs);
dispatch({ dispatch({
type: "universe: column load success", type: "universe: column load success",
dim: "obsLayout", dim: "obsLayout",
dataframe: df dataframe: df,
}); });
}); });
} }
@@ -108,7 +109,7 @@ Bootstrap application with the initial data loading.
* /layout - all default layout * /layout - all default layout
*/ */
const doInitialDataLoad = () => const doInitialDataLoad = () =>
catchErrorsWrap(async dispatch => { catchErrorsWrap(async (dispatch) => {
dispatch({ type: "initial data load start" }); dispatch({ type: "initial data load start" });
try { try {
@@ -116,8 +117,8 @@ const doInitialDataLoad = () =>
Step 1 - config & schema, all JSON Step 1 - config & schema, all JSON
*/ */
const requestJson = ["config", "schema"] const requestJson = ["config", "schema"]
.map(r => `${globals.API.prefix}${globals.API.version}${r}`) .map((r) => `${globals.API.prefix}${globals.API.version}${r}`)
.map(url => doJsonRequest(url)); .map((url) => doJsonRequest(url));
const stepOneResults = await Promise.all(requestJson); const stepOneResults = await Promise.all(requestJson);
/* set config defaults */ /* set config defaults */
const config = { ...globals.configDefaults, ...stepOneResults[0].config }; const config = { ...globals.configDefaults, ...stepOneResults[0].config };
@@ -125,11 +126,11 @@ const doInitialDataLoad = () =>
const universe = Universe.createUniverseFromResponse(config, schema); const universe = Universe.createUniverseFromResponse(config, schema);
dispatch({ dispatch({
type: "universe exists, but loading is still in progress", type: "universe exists, but loading is still in progress",
universe universe,
}); });
dispatch({ dispatch({
type: "configuration load complete", type: "configuration load complete",
config config,
}); });
/* /*
@@ -137,7 +138,7 @@ const doInitialDataLoad = () =>
*/ */
await Promise.all([ await Promise.all([
layoutFetchAndLoad(dispatch, schema), layoutFetchAndLoad(dispatch, schema),
varAnnotationFetchAndLoad(dispatch, schema) varAnnotationFetchAndLoad(dispatch, schema),
]); ]);
/* /*
@@ -147,7 +148,7 @@ const doInitialDataLoad = () =>
dispatch({ dispatch({
type: "initial data load complete (universe exists)", type: "initial data load complete (universe exists)",
universe universe,
}); });
} catch (error) { } catch (error) {
dispatch({ type: "initial data load error", error }); dispatch({ type: "initial data load error", error });
@@ -164,7 +165,7 @@ const setWorldToSelection = () => (dispatch, getState) => {
type: "set World to current selection", type: "set World to current selection",
universe, universe,
world, world,
crossfilter crossfilter,
}); });
}; };
@@ -175,6 +176,11 @@ const dispatchExpressionErrors = (dispatch, res) => {
throw new Error(msg); throw new Error(msg);
}; };
/* double URI encode - needed for query-param filters */
function dubEncURIComponent(s) {
return encodeURIComponent(encodeURIComponent(s));
}
/* /*
Fetch expression vectors for each gene in genes. This is NOT an action Fetch expression vectors for each gene in genes. This is NOT an action
function, but rather a helper to be called from an action helper that function, but rather a helper to be called from an action helper that
@@ -188,51 +194,31 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
const varIndexName = universe.schema.annotations.var.index; const varIndexName = universe.schema.annotations.var.index;
/* helper for this function only */ /* helper for this function only */
const fetchData = async geneNames => { const fetchData = async (geneNames) => {
const res = await fetch( const query = geneNames
`${globals.API.prefix}${globals.API.version}data/var`, .map(
{ (g) =>
method: "PUT", `var:${dubEncURIComponent(varIndexName)}=${dubEncURIComponent(g)}`
body: JSON.stringify({ )
filter: { .join("&");
var: { const url = `${globals.API.prefix}${globals.API.version}data/var?${query}`;
annotation_value: [{ name: varIndexName, values: geneNames }] return doBinaryRequest(url).then((buffer) =>
} // TODO: why convert to an Object and not a Dataframe?
} Universe.convertDataFBStoObject(universe, buffer)
}),
headers: new Headers({
accept: "application/octet-stream",
"Content-Type": "application/json"
}),
credentials: "include"
}
); );
if (
!res.ok ||
res.headers.get("Content-Type") !== "application/octet-stream"
) {
// WILL throw
return dispatchExpressionErrors(dispatch, res);
}
const data = await res.arrayBuffer();
return Universe.convertDataFBStoObject(universe, data);
}; };
/* preload data already in cache */ /* preload data already in cache */
let expressionData = _.transform( let expressionData = genes.reduce((acc, g) => {
genes, const data = universe.varData.col(g);
(expData, g) => { if (data) {
const data = universe.varData.col(g); acc[g] = data.asArray();
if (data) { }
expData[g] = data.asArray(); return acc;
} }, {}); // --> { gene: data }
},
{}
); // --> { gene: data }
/* make a list of genes for which we do not have data */ /* make a list of genes for which we do not have data */
const genesToFetch = _.filter(genes, g => expressionData[g] === undefined); const genesToFetch = genes.filter((g) => expressionData[g] === undefined);
dispatch({ type: "expression load start" }); dispatch({ type: "expression load start" });
@@ -242,7 +228,7 @@ async function _doRequestExpressionData(dispatch, getState, genes) {
const newExpressionData = await fetchData(genesToFetch); const newExpressionData = await fetchData(genesToFetch);
expressionData = { expressionData = {
...expressionData, ...expressionData,
...newExpressionData ...newExpressionData,
}; };
} catch (error) { } catch (error) {
dispatch({ type: "expression load error", error }); dispatch({ type: "expression load error", error });
@@ -264,19 +250,19 @@ function requestSingleGeneExpressionCountsForColoringPOST(gene) {
type: "color by expression", type: "color by expression",
gene, gene,
data: { data: {
[gene]: world.varData.col(gene).asArray() [gene]: world.varData.col(gene).asArray(),
} },
}); });
} catch (error) { } catch (error) {
dispatch({ dispatch({
type: "get single gene expression for coloring error", type: "get single gene expression for coloring error",
error error,
}); });
} }
}; };
} }
const requestUserDefinedGene = gene => async (dispatch, getState) => { const requestUserDefinedGene = (gene) => async (dispatch, getState) => {
dispatch({ type: "request user defined gene started" }); dispatch({ type: "request user defined gene started" });
try { try {
await await _doRequestExpressionData(dispatch, getState, [gene]); await await _doRequestExpressionData(dispatch, getState, [gene]);
@@ -287,13 +273,13 @@ const requestUserDefinedGene = gene => async (dispatch, getState) => {
type: "request user defined gene success", type: "request user defined gene success",
data: { data: {
genes: [gene], genes: [gene],
expression: world.varData.col(gene).asArray() expression: world.varData.col(gene).asArray(),
} },
}); });
} catch (error) { } catch (error) {
return dispatch({ return dispatch({
type: "request user defined gene error", type: "request user defined gene error",
error error,
}); });
} }
}; };
@@ -315,7 +301,7 @@ const dispatchDiffExpErrors = (dispatch, response) => {
dispatchNetworkErrorMessageToUser(msg); dispatchNetworkErrorMessageToUser(msg);
dispatch({ dispatch({
type: "request differential expression error", type: "request differential expression error",
error: new Error(msg) error: new Error(msg),
}); });
} }
} }
@@ -353,15 +339,15 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
method: "POST", method: "POST",
headers: new Headers({ headers: new Headers({
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json" "Content-Type": "application/json",
}), }),
body: JSON.stringify({ body: JSON.stringify({
mode: "topN", mode: "topN",
count: num_genes, count: num_genes,
set1: { filter: { obs: { index: set1 } } }, set1: { filter: { obs: { index: set1 } } },
set2: { filter: { obs: { index: set2 } } } set2: { filter: { obs: { index: set2 } } },
}), }),
credentials: "include" credentials: "include",
} }
); );
@@ -371,7 +357,7 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
const data = await res.json(); const data = await res.json();
// result is [ [varIdx, ...], ... ] // result is [ [varIdx, ...], ... ]
const topNGenes = _.map(data, r => const topNGenes = data.map((r) =>
universe.varAnnotations.at(r[0], varIndexName) universe.varAnnotations.at(r[0], varIndexName)
); );
@@ -379,17 +365,22 @@ const requestDifferentialExpression = (set1, set2, num_genes = 10) => async (
Kick off secondary action to fetch all of the expression data for the Kick off secondary action to fetch all of the expression data for the
topN expressed genes. topN expressed genes.
*/ */
await _doRequestExpressionData(dispatch, getState, topNGenes); const plimit = new PromiseLimit(5);
await Promise.all(
topNGenes.map((gene) =>
plimit.add(() => _doRequestExpressionData(dispatch, getState, [gene]))
)
);
/* then send the success case action through */ /* then send the success case action through */
return dispatch({ return dispatch({
type: "request differential expression success", type: "request differential expression success",
data data,
}); });
} catch (error) { } catch (error) {
return dispatch({ return dispatch({
type: "request differential expression error", type: "request differential expression error",
error error,
}); });
} }
}; };
@@ -399,7 +390,7 @@ const resetWorldToUniverse = () => (dispatch, getState) => {
reembedResetWorldToUniverse(dispatch, getState); reembedResetWorldToUniverse(dispatch, getState);
dispatch({ dispatch({
type: "reset World to eq Universe", type: "reset World to eq Universe",
universe universe,
}); });
}; };
@@ -409,12 +400,12 @@ const saveObsAnnotations = () => async (dispatch, getState) => {
const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations; const { dataCollectionNameIsReadOnly, dataCollectionName } = annotations;
dispatch({ dispatch({
type: "writable obs annotations - save started" type: "writable obs annotations - save started",
}); });
const writableAnnotations = schema.annotations.obs.columns const writableAnnotations = schema.annotations.obs.columns
.filter(s => s.writable) .filter((s) => s.writable)
.map(s => s.name); .map((s) => s.name);
const df = obsAnnotations.subset(null, writableAnnotations); const df = obsAnnotations.subset(null, writableAnnotations);
const matrix = MatrixFBS.encodeMatrixFBS(df); const matrix = MatrixFBS.encodeMatrixFBS(df);
try { try {
@@ -430,28 +421,28 @@ const saveObsAnnotations = () => async (dispatch, getState) => {
method: "PUT", method: "PUT",
body: matrix, body: matrix,
headers: new Headers({ headers: new Headers({
"Content-Type": "application/octet-stream" "Content-Type": "application/octet-stream",
}), }),
credentials: "include" credentials: "include",
} }
); );
if (res.ok) { if (res.ok) {
dispatch({ dispatch({
type: "writable obs annotations - save complete", type: "writable obs annotations - save complete",
obsAnnotations obsAnnotations,
}); });
} else { } else {
dispatch({ dispatch({
type: "writable obs annotations - save error", type: "writable obs annotations - save error",
message: `HTTP error ${res.status} - ${res.statusText}`, message: `HTTP error ${res.status} - ${res.statusText}`,
res res,
}); });
} }
} catch (error) { } catch (error) {
dispatch({ dispatch({
type: "writable obs annotations - save error", type: "writable obs annotations - save error",
message: error.toString(), message: error.toString(),
error error,
}); });
} }
}; };
@@ -464,5 +455,5 @@ export default {
requestReembed, requestReembed,
resetWorldToUniverse, resetWorldToUniverse,
saveObsAnnotations, saveObsAnnotations,
setWorldToSelection setWorldToSelection,
}; };
+27 -26
View File
@@ -20,8 +20,6 @@ const Controls = (
scatterplotXXaccessor: null, // just easier to read scatterplotXXaccessor: null, // just easier to read
scatterplotYYaccessor: null, scatterplotYYaccessor: null,
graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */, graphRenderCounter: 0 /* integer as <Component key={graphRenderCounter} - a change in key forces a remount */,
__storedStateForCelllist1__: null /* will need procedural control of brush ie., brush.extent https://bl.ocks.org/micahstubbs/3cda05ca68cba260cb81 */,
__storedStateForCelllist2__: null
}, },
action, action,
nextSharedState, nextSharedState,
@@ -62,7 +60,7 @@ const Controls = (
universeExists && universeExists &&
embeddingsExist && embeddingsExist &&
varAnnotationsIndexExists varAnnotationsIndexExists
) ),
}; };
} }
case "initial data load complete (universe exists)": { case "initial data load complete (universe exists)": {
@@ -71,38 +69,42 @@ const Controls = (
...state, ...state,
loading: false, loading: false,
error: null, error: null,
resettingInterface: false resettingInterface: false,
}; };
} }
case "reset World to eq Universe": { case "reset World to eq Universe": {
const [ newUserDefinedGenes, newDiffExpGenes ] = subsetAndResetGeneLists(state); const [newUserDefinedGenes, newDiffExpGenes] = subsetAndResetGeneLists(
state
);
return { return {
...state, ...state,
resettingInterface: false, resettingInterface: false,
userDefinedGenes: newUserDefinedGenes, userDefinedGenes: newUserDefinedGenes,
diffexpGenes: newDiffExpGenes diffexpGenes: newDiffExpGenes,
}; };
} }
case "set World to current selection": { case "set World to current selection": {
const [ newUserDefinedGenes, newDiffExpGenes ] = subsetAndResetGeneLists(state); const [newUserDefinedGenes, newDiffExpGenes] = subsetAndResetGeneLists(
state
);
return { return {
...state, ...state,
loading: false, loading: false,
error: null, error: null,
userDefinedGenes: newUserDefinedGenes, userDefinedGenes: newUserDefinedGenes,
diffexpGenes: newDiffExpGenes diffexpGenes: newDiffExpGenes,
}; };
} }
case "request user defined gene started": { case "request user defined gene started": {
return { return {
...state, ...state,
userDefinedGenesLoading: true userDefinedGenesLoading: true,
}; };
} }
case "request user defined gene error": { case "request user defined gene error": {
return { return {
...state, ...state,
userDefinedGenesLoading: false userDefinedGenesLoading: false,
}; };
} }
case "request user defined gene success": { case "request user defined gene success": {
@@ -113,50 +115,49 @@ const Controls = (
return { return {
...state, ...state,
userDefinedGenes: _userDefinedGenes, userDefinedGenes: _userDefinedGenes,
userDefinedGenesLoading: false userDefinedGenesLoading: false,
}; };
} }
case "request differential expression success": { case "request differential expression success": {
const { world } = prevSharedState; const { world } = prevSharedState;
const varIndexName = world.schema.annotations.var.index; const varIndexName = world.schema.annotations.var.index;
const _diffexpGenes = []; const _diffexpGenes = [];
action.data.forEach(d => { action.data.forEach((d) => {
_diffexpGenes.push(world.varAnnotations.at(d[0], varIndexName)); _diffexpGenes.push(world.varAnnotations.at(d[0], varIndexName));
}); });
return { return {
...state, ...state,
diffexpGenes: _diffexpGenes diffexpGenes: _diffexpGenes,
}; };
} }
case "clear differential expression": { case "clear differential expression": {
return { return {
...state, ...state,
diffexpGenes: [] diffexpGenes: [],
}; };
} }
case "clear user defined gene": { case "clear user defined gene": {
const { userDefinedGenes } = state; const { userDefinedGenes } = state;
const newUserDefinedGenes = _.filter( const newUserDefinedGenes = _.filter(
userDefinedGenes, userDefinedGenes,
d => d !== action.data (d) => d !== action.data
); );
return { return {
...state, ...state,
userDefinedGenes: newUserDefinedGenes userDefinedGenes: newUserDefinedGenes,
}; };
} }
case "clear all user defined genes": { case "clear all user defined genes": {
return { return {
...state, ...state,
userDefinedGenes: [] userDefinedGenes: [],
}; };
} }
case "expression load error":
case "initial data load error": { case "initial data load error": {
return { return {
...state, ...state,
loading: false, loading: false,
error: action.error error: action.error,
}; };
} }
@@ -166,24 +167,24 @@ const Controls = (
case "change graph interaction mode": case "change graph interaction mode":
return { return {
...state, ...state,
graphInteractionMode: action.data graphInteractionMode: action.data,
}; };
case "change opacity deselected cells in 2d graph background": case "change opacity deselected cells in 2d graph background":
return { return {
...state, ...state,
opacityForDeselectedCells: action.data opacityForDeselectedCells: action.data,
}; };
case "increment graph render counter": { case "increment graph render counter": {
const c = state.graphRenderCounter + 1; const c = state.graphRenderCounter + 1;
return { return {
...state, ...state,
graphRenderCounter: c graphRenderCounter: c,
}; };
} }
case "interface reset started": { case "interface reset started": {
return { return {
...state, ...state,
resettingInterface: true resettingInterface: true,
}; };
} }
@@ -193,18 +194,18 @@ const Controls = (
case "set scatterplot x": case "set scatterplot x":
return { return {
...state, ...state,
scatterplotXXaccessor: action.data scatterplotXXaccessor: action.data,
}; };
case "set scatterplot y": case "set scatterplot y":
return { return {
...state, ...state,
scatterplotYYaccessor: action.data scatterplotYYaccessor: action.data,
}; };
case "clear scatterplot": case "clear scatterplot":
return { return {
...state, ...state,
scatterplotXXaccessor: null, scatterplotXXaccessor: null,
scatterplotYYaccessor: null scatterplotYYaccessor: null,
}; };
default: default:
+24 -21
View File
@@ -6,7 +6,7 @@ import { postNetworkErrorToast } from "../components/framework/toasters";
dispatch an action error to the user. Currently we use dispatch an action error to the user. Currently we use
async toasts. async toasts.
*/ */
export const dispatchNetworkErrorMessageToUser = message => export const dispatchNetworkErrorMessageToUser = (message) =>
postNetworkErrorToast(message); postNetworkErrorToast(message);
/* /*
@@ -14,7 +14,7 @@ Catch unexpected errors and make sure we don't lose them!
*/ */
export function catchErrorsWrap(fn, dispatchToUser = false) { export function catchErrorsWrap(fn, dispatchToUser = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
fn(dispatch, getState).catch(error => { fn(dispatch, getState).catch((error) => {
console.error(error); console.error(error);
if (dispatchToUser) { if (dispatchToUser) {
dispatchNetworkErrorMessageToUser(error.message); dispatchNetworkErrorMessageToUser(error.message);
@@ -29,30 +29,33 @@ Wrapper to perform async fetch with some modest error handling
and decoding. and decoding.
*/ */
const doFetch = async (url, acceptType) => { const doFetch = async (url, acceptType) => {
const res = await fetch(url, { try {
method: "get", const res = await fetch(url, {
headers: new Headers({ method: "get",
Accept: acceptType headers: new Headers({
}), Accept: acceptType,
credentials: "include" }),
}); credentials: "include",
if (res.ok && res.headers.get("Content-Type").includes(acceptType)) { });
return res; if (res.ok && res.headers.get("Content-Type").includes(acceptType)) {
return res;
}
// else an error
const msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
} catch (e) {
// network error
const msg = "Unexpected HTTP error";
dispatchNetworkErrorMessageToUser(msg);
throw e;
} }
// else an error
let msg = `Unexpected HTTP response ${res.status}, ${res.statusText}`;
const body = await res.text();
if (body && body.length > 0) {
msg = `${msg} -- ${body}`;
}
dispatchNetworkErrorMessageToUser(msg);
throw new Error(msg);
}; };
/* /*
Wrapper to perform an async fetch and JSON decode response. Wrapper to perform an async fetch and JSON decode response.
*/ */
export const doJsonRequest = async url => { export const doJsonRequest = async (url) => {
const res = await doFetch(url, "application/json"); const res = await doFetch(url, "application/json");
return res.json(); return res.json();
}; };
@@ -60,7 +63,7 @@ export const doJsonRequest = async url => {
/* /*
Wrapper to perform an async fetch for binary data. Wrapper to perform an async fetch for binary data.
*/ */
export const doBinaryRequest = async url => { export const doBinaryRequest = async (url) => {
const res = await doFetch(url, "application/octet-stream"); const res = await doFetch(url, "application/octet-stream");
return res.arrayBuffer(); return res.arrayBuffer();
}; };
+5
View File
@@ -198,6 +198,11 @@ class DataVarAPI(Resource):
def put(self, data_adaptor): def put(self, data_adaptor):
return common_rest.data_var_put(request, data_adaptor) return common_rest.data_var_put(request, data_adaptor)
@cache_control(public=True, max_age=ONE_WEEK)
@rest_get_data_adaptor
def get(self, data_adaptor):
return common_rest.data_var_get(request, data_adaptor)
class DiffExpObsAPI(Resource): class DiffExpObsAPI(Resource):
@cache_control(no_store=True) @cache_control(no_store=True)
+81
View File
@@ -3,6 +3,7 @@ from http import HTTPStatus
import copy import copy
import logging import logging
from flask import make_response, jsonify, current_app, abort from flask import make_response, jsonify, current_app, abort
from werkzeug.urls import url_unquote
from server.common.constants import Axis, DiffExpMode, JSON_NaN_to_num_warning_msg from server.common.constants import Axis, DiffExpMode, JSON_NaN_to_num_warning_msg
from server.common.errors import ( from server.common.errors import (
FilterError, FilterError,
@@ -31,6 +32,70 @@ def abort_and_log(code, logmsg, loglevel=logging.DEBUG, include_exc_info=False):
return abort(code) return abort(code)
def _query_parameter_to_filter(args):
"""
Convert an annotation value filter, if present in the query args,
into the standard dict filter format used by internal code.
Query param filters look like: <axis>:name=value, where value
may be one of:
- a range, min,max, where either may be an open range by using an asterisc, eg, 10,*
- a value
Eg,
...?tissue=lung&obs:tissue=heart&obs:num_reads=1000,*
"""
filters = {
"obs": {},
"var": {},
}
# args has already been url-unquoted once. We assume double escaping
# on name and value.
try:
for key, value in args.items(multi=True):
axis, name = key.split(':')
if axis not in ("obs", "var"):
raise FilterError("unknown filter axis")
name = url_unquote(name)
current = filters[axis].setdefault(name, {"name": name})
val_split = value.split(',')
if len(val_split) == 1:
if 'min' in current or 'max' in current:
raise FilterError("do not mix range and value filters")
value = url_unquote(value)
values = current.setdefault("values", [])
values.append(value)
elif len(val_split) == 2:
if len(current) > 1:
raise FilterError("duplicate range specification")
min = url_unquote(val_split[0])
max = url_unquote(val_split[1])
if min != '*':
current["min"] = float(min)
if max != "*":
current["max"] = float(max)
if len(current) < 2:
raise FilterError("must specify at least min or max in range filter")
else:
raise FilterError("badly formated filter value")
except ValueError as e:
raise FilterError(str(e))
result = {}
for axis in ("obs", "var"):
axis_filter = filters[axis]
if len(axis_filter) > 0:
result[axis] = {
"annotation_value": [val for val in axis_filter.values()]
}
return result
def schema_get_helper(data_adaptor, annotations): def schema_get_helper(data_adaptor, annotations):
"""helper function to gather the schema from the data source and annotations""" """helper function to gather the schema from the data source and annotations"""
schema = data_adaptor.get_schema() schema = data_adaptor.get_schema()
@@ -143,6 +208,22 @@ def data_var_put(request, data_adaptor):
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True) return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
def data_var_get(request, data_adaptor):
preferred_mimetype = request.accept_mimetypes.best_match(["application/octet-stream"])
if preferred_mimetype != "application/octet-stream":
return abort(HTTPStatus.NOT_ACCEPTABLE)
try:
filter = _query_parameter_to_filter(request.args)
return make_response(
data_adaptor.data_frame_to_fbs_matrix(filter, axis=Axis.VAR),
HTTPStatus.OK,
{"Content-Type": "application/octet-stream"},
)
except (FilterError, ValueError, ExceedsLimitError) as e:
return abort_and_log(HTTPStatus.BAD_REQUEST, str(e), include_exc_info=True)
def diffexp_obs_post(request, data_adaptor): def diffexp_obs_post(request, data_adaptor):
if not data_adaptor.config.diffexp__enable: if not data_adaptor.config.diffexp__enable:
return abort(HTTPStatus.NOT_IMPLEMENTED) return abort(HTTPStatus.NOT_IMPLEMENTED)
+20
View File
@@ -206,6 +206,13 @@ class EndPoints(object):
result = self.session.put(url, headers=header) result = self.session.put(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST) self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
def test_data_get_fbs(self):
endpoint = f"data/var"
url = f"{self.URL_BASE}{endpoint}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.BAD_REQUEST)
def test_data_put_filter_fbs(self): def test_data_put_filter_fbs(self):
endpoint = f"data/var" endpoint = f"data/var"
url = f"{self.URL_BASE}{endpoint}" url = f"{self.URL_BASE}{endpoint}"
@@ -222,6 +229,19 @@ class EndPoints(object):
self.assertEqual(len(df["columns"]), df["n_cols"]) self.assertEqual(len(df["columns"]), df["n_cols"])
self.assertListEqual(df["col_idx"].tolist(), [0, 1, 4]) self.assertListEqual(df["col_idx"].tolist(), [0, 1, 4])
def test_data_get_filter_fbs(self):
index_col_name = self.schema["schema"]["annotations"]["var"]["index"]
query = f"var:{index_col_name}=SIK1"
endpoint = f"data/var"
url = f"{self.URL_BASE}{endpoint}?{query}"
header = {"Accept": "application/octet-stream"}
result = self.session.get(url, headers=header)
self.assertEqual(result.status_code, HTTPStatus.OK)
self.assertEqual(result.headers["Content-Type"], "application/octet-stream")
df = decode_fbs.decode_matrix_FBS(result.content)
self.assertEqual(df["n_rows"], 2638)
self.assertEqual(df["n_cols"], 1)
def test_data_put_single_var(self): def test_data_put_single_var(self):
endpoint = f"data/var" endpoint = f"data/var"
url = f"{self.URL_BASE}{endpoint}" url = f"{self.URL_BASE}{endpoint}"
+83
View File
@@ -0,0 +1,83 @@
import unittest
from urllib.parse import parse_qs
from werkzeug.datastructures import MultiDict
from server.common.rest import _query_parameter_to_filter
from server.common.errors import FilterError
def _qsparse(qs):
""" emulate what Flask/Werkzeug do to our QS """
return MultiDict(parse_qs(qs))
class FilterParseTests(unittest.TestCase):
""" Test cases for various filter parsing """
def test_queryparam_to_filter_parse(self):
# categories
self.assertEqual(_query_parameter_to_filter(_qsparse("obs:foo=bar&var:baz=133&var:baz=A&obs:baz=foo")), {
"obs": {
"annotation_value": [
{"name": "foo", "values": ["bar"]},
{"name": "baz", "values": ["foo"]},
]
},
"var": {
"annotation_value": [
{"name": "baz", "values": ["133", "A"]}
]
}
})
# ranges
self.assertEqual(_query_parameter_to_filter(_qsparse("obs:A=1,99&obs:B=*,100&obs:C=0,*")), {
"obs": {
"annotation_value": [
{"name": "A", "min": 1, "max": 99.},
{"name": "B", "max": 100.},
{"name": "C", "min": 0.},
]
},
})
# combo
self.assertEqual(_query_parameter_to_filter(_qsparse("var:B=YES&var:A=1,99&var:B=NO")), {
"var": {
"annotation_value": [
{"name": "B", "values": ["YES", "NO"]},
{"name": "A", "min": 1., "max": 99.},
]
},
})
def test_queryparam_to_filter_escaping(self):
self.assertEqual(_query_parameter_to_filter(_qsparse("obs:var=%2521%252C%253AOK%253D&obs:A%2521=YO")), {
"obs": {
"annotation_value": [
{"name": "var", "values": ["!,:OK="]},
{"name": "A!", "values": ["YO"]},
],
},
})
def test_queryparam_to_filter_errors(self):
# should raise FilterError
filter_errors = [
"foo=bar", # no axis
"X=&Y=3", # no value
"X&Y=3", # no value
"moo:foo=bar", # bad axis
"obs:x=1,A", # non-numeric range
"var:X=1,2&var:X=3,4", # duplicate ranges
"var:Y=,",
"var:Y=2,",
"var:Y=,5",
"var:Y=*,",
"var:Y=,*",
"var:Y=*,*",
]
for qs in filter_errors:
with self.assertRaises(FilterError):
_query_parameter_to_filter(_qsparse(qs))