auspice
Version:
Web app for visualizing pathogen evolution
269 lines (251 loc) • 10.6 kB
JavaScript
import queryString from "query-string";
import * as types from "./types";
import { getServerAddress } from "../util/globals";
import { goTo404 } from "./navigation";
import { createStateFromQueryOrJSONs, createTreeTooState } from "./recomputeReduxState";
import { loadFrequencies } from "./frequencies";
import { fetchJSON } from "../util/serverInteraction";
import { warningNotification, errorNotification } from "./notifications";
import { hasExtension, getExtension } from "../util/extensions";
/**
* Sends a GET request to the `/charon` web API endpoint requesting data.
* Throws an `Error` if the response is not successful or is not a redirect.
*
* Returns a `Promise` containing the `Response` object. JSON data must be
* accessed from the `Response` object using the `.json()` method.
*
* @param {String} prefix: the main dataset information pertaining to the query,
* e.g. 'flu'
* @param {Object} additionalQueries: additional information to be parsed as a
* query string such as `type` (`String`) or `narrative` (`Boolean`).
*/
const getDatasetFromCharon = (prefix, {type, narrative=false}={}) => {
let path = `${getServerAddress()}/${narrative?"getNarrative":"getDataset"}`;
path += `?prefix=${prefix}`;
if (type) path += `&type=${type}`;
const p = fetch(path)
.then((res) => {
if (res.status !== 200) {
throw new Error(res.statusText);
}
return res;
});
return p;
};
/**
* Requests data from a hardcoded web API endpoint.
* Throws an `Error` if the response is not successful.
*
* Returns a `Promise` containing the `Response` object. JSON data must be
* accessed from the `Response` object using the `.json()` method.
*
* Note: we currently expect a single dataset to be present in "hardcodedDataPaths".
* This may be extended to multiple in the future...
*
* @param {String} prefix: the main dataset information pertaining to the query,
* e.g. 'flu'
* @param {Object} additionalQueries: additional information to be parsed as a
* query string such as `type` (`String`) or `narrative` (`Boolean`).
*/
const getHardcodedData = (prefix, {type="mainJSON", narrative=false}={}) => {
const datapaths = getExtension("hardcodedDataPaths");
const p = fetch(datapaths[type])
.then((res) => {
if (res.status !== 200) {
throw new Error(res.statusText);
}
return res;
});
return p;
};
const getDataset = hasExtension("hardcodedDataPaths") ? getHardcodedData : getDatasetFromCharon;
/**
* given a url, which dataset fetches should be made?
* If a second tree is defined - e.g.
* `flu/seasonal/h3n2/ha/2y:flu/seasonal/h3n2/na/2y`.
* then we want to make two fetches - one for
* `flu/seasonal/h3n2/ha/2y` and one for `flu/seasonal/h3n2/na/2y`.
*
* @returns {Array} [0] {string} url, modified as needed to represent main dataset
* [1] {string | undefined} secondTreeUrl, if applicable
*/
const collectDatasetFetchUrls = (url) => {
let secondTreeUrl;
if (url.includes(":")) {
const parts = url.replace(/^\//, '')
.replace(/\/$/, '')
.split(":");
url = parts[0];
secondTreeUrl = parts[1];
}
return [url, secondTreeUrl];
};
/**
* This is for processing a second tree using the deprecated
* syntax of declaring second trees, e.g. `flu/seasonal/h3n2/ha:na/2y`
* We are keeping this to allow backwards compatibility.
*
* given a url to fetch, check if a second tree is defined.
* e.g. `ha:na`. If so, then we want to make two fetches,
* one for `ha` and one for `na`.
*
* @returns {Array} [0] {string} url, modified as needed to represent main tree
* [1] {string | undefined} secondTreeUrl
* [2] {string | undefined} string of old syntax
*/
const collectDatasetFetchUrlsDeprecatedSyntax = (url) => {
let secondTreeUrl;
let treeName;
let secondTreeName;
const parts = url.replace(/^\//, '')
.replace(/\/$/, '')
.split("/");
for (let i = 0; i < parts.length; i++) {
if (parts[i].indexOf(":") !== -1) {
[treeName, secondTreeName] = parts[i].split(":");
parts[i] = treeName;
url = parts.join("/"); // this is the first tree URL
parts[i] = secondTreeName;
secondTreeUrl = parts.join("/"); // this is the second tree URL
break;
}
}
return [url, secondTreeUrl, treeName.concat(":", secondTreeName)];
};
const fetchDataAndDispatch = async (dispatch, url, query, narrativeBlocks) => {
/* Once upon a time one could specify a second tree via a `?tt=tree_name`.
This is no longer supported, however we still display an error message. */
if (query.tt) {
dispatch(errorNotification({
message: `Specifing a second tree via '?tt=${query.tt}' is no longer supported.`,
details: "The new syntax requires the complete name for both trees. " +
"For example, instead of 'flu/seasonal/h3n2/ha/2y?tt=na' you must " +
"specify 'flu/seasonal/h3n2/ha/2y:flu/seasonal/h3n2/na/2y' "
}));
}
let pathnameShouldBe = url; /* the pathname to display in the URL */
let [mainDatasetUrl, secondTreeUrl] = collectDatasetFetchUrls(url);
/* fetch the dataset JSON + the dataset JSON of a second tree if applicable */
let datasetJson;
let secondTreeDataset = false;
try {
if (!secondTreeUrl) {
const mainDatasetResponse = await getDataset(mainDatasetUrl);
datasetJson = await mainDatasetResponse.json();
pathnameShouldBe = queryString.parse(mainDatasetResponse.url.split("?")[1]).prefix;
} else {
try {
/* TO DO -- we used to fetch both trees at once, and the server would provide
* the following info accordingly. This required `recomputeReduxState` to be
* overly complicated. Since we have 2 fetches, could we simplify things
* and make `recomputeReduxState` for the first tree followed by another
* state recomputation? */
const mainDatasetResponse = await getDataset(mainDatasetUrl);
datasetJson = await mainDatasetResponse.json();
secondTreeDataset = await getDataset(secondTreeUrl)
.then((res) => res.json());
} catch (e) {
/* If the url is in the old syntax (e.g. `ha:na`) then `collectDatasetFetchUrls`
* will return incorrect dataset URLs (perhaps for both trees)
* In this case, we will try to parse the url again according to the old syntax
* and try to get the dataset for the main tree and second tree again.
* Also displays warning to the user to let them know the old syntax is deprecated. */
let oldSyntax;
[mainDatasetUrl, secondTreeUrl, oldSyntax] = collectDatasetFetchUrlsDeprecatedSyntax(url);
pathnameShouldBe = `${mainDatasetUrl}:${secondTreeUrl}`
const mainDatasetResponse = await getDataset(mainDatasetUrl);
datasetJson = await mainDatasetResponse.json();
secondTreeDataset = await getDataset(secondTreeUrl)
.then((res) => res.json());
dispatch(warningNotification({
message: `Specifing a second tree via "${oldSyntax}" is deprecated.`,
details: "The url has been modified to reflect the new syntax."
}));
}
}
dispatch({
type: types.CLEAN_START,
pathnameShouldBe,
...createStateFromQueryOrJSONs({
json: datasetJson,
secondTreeDataset,
query,
narrativeBlocks,
mainTreeName: secondTreeUrl ? mainDatasetUrl : null,
secondTreeName: secondTreeUrl ? secondTreeUrl : null
})
});
} catch (err) {
if (err.message === "No Content") { // status code 204
/* TODO: add more helper functions for moving between pages in auspice */
return dispatch({
type: types.PAGE_CHANGE,
displayComponent: "splash",
pushState: true
});
}
console.error(err, err.message);
dispatch(goTo404(`Couldn't load JSONs for ${url}`));
return;
}
/* do we have frequencies to display? */
if (datasetJson.meta.panels && datasetJson.meta.panels.indexOf("frequencies") !== -1) {
try {
const frequencyData = await getDataset(mainDatasetUrl, {type: "tip-frequencies"})
.then((res) => res.json());
dispatch(loadFrequencies(frequencyData));
} catch (err) {
console.error("Failed to fetch frequencies", err.message)
dispatch(warningNotification({message: "Failed to fetch frequencies"}));
}
}
/* Get available datasets -- this is needed for the sidebar dataset-change dropdowns etc */
try {
const availableDatasets = await fetchJSON(`${getServerAddress()}/getAvailable?prefix=${window.location.pathname}`)
dispatch({type: types.SET_AVAILABLE, data: availableDatasets});
} catch (err) {
console.error("Failed to fetch available datasets", err.message)
dispatch(warningNotification({message: "Failed to fetch available datasets"}));
}
};
export const loadSecondTree = (secondTreeUrl, firstTreeUrl) => async (dispatch, getState) => {
let secondJson;
try {
secondJson = await getDataset(secondTreeUrl)
.then((res) => res.json());
} catch (err) {
console.error("Failed to fetch additional tree", err.message);
dispatch(warningNotification({message: "Failed to fetch second tree"}));
return;
}
const oldState = getState();
const newState = createTreeTooState({treeTooJSON: secondJson.tree, oldState, originalTreeUrl: firstTreeUrl, secondTreeUrl: secondTreeUrl});
dispatch({type: types.TREE_TOO_DATA, ...newState});
};
export const loadJSONs = ({url = window.location.pathname, search = window.location.search} = {}) => {
return (dispatch, getState) => {
const { tree } = getState();
if (tree.loaded) {
dispatch({type: types.DATA_INVALID});
}
const query = queryString.parse(search);
if (url.indexOf("narratives") === -1) {
fetchDataAndDispatch(dispatch, url, query);
} else {
/* we want to have an additional fetch to get the narrative JSON, which in turn
tells us which data JSON to fetch... */
getDatasetFromCharon(url, {narrative: true})
.then((res) => res.json())
.then((blocks) => {
const firstURL = blocks[0].dataset;
const firstQuery = queryString.parse(blocks[0].query);
if (query.n) firstQuery.n = query.n;
fetchDataAndDispatch(dispatch, firstURL, firstQuery, blocks);
})
.catch((err) => {
console.error("Error obtaining narratives", err.message);
dispatch(goTo404(`Couldn't load narrative for ${url}`));
});
};
};
};