diff --git a/src/APIInterface.mjs b/src/APIInterface.mjs
new file mode 100644
index 00000000..4e7b86ef
--- /dev/null
+++ b/src/APIInterface.mjs
@@ -0,0 +1,42 @@
+
+// Interface for handling function called from the tubemap frontend
+// Abstract class expecting different implmentations of the following functions
+// Substituting different subclasses should allow the functions to give the same result
+export class APIInterface {
+ // Takes in and process a tube map view(viewTarget) from the tubemap container
+ // Expects a object to be returned with the necessary information to draw a tubemap from vg
+ // object should contain keys: graph, gam, region, coloredNodes
+ async getChunkedData(viewTarget) {
+ throw new Error("getChunkedData function not implemented");
+ }
+
+ // Returns files used to determine what options are available in the track picker
+ // Returns object with keys: files, bedFiles
+ async getFilenames() {
+ throw new Error("getFilenames function not implemented");
+ }
+
+ // Takes in a bedfile path or a url pointing to a raw bed file
+ // Returns object with key: bedRegions
+ // bedRegions contains information extrapolated from each line of the bedfile
+ async getBedRegions(bedFile) {
+ throw new Error("getBedRegions function not implemented");
+ }
+
+ // Takes in a graphFile path
+ // Returns object with key: pathNames
+ // Returns pathnames available in a graphfile
+ async getPathNames(graphFile) {
+ throw new Error("getPathNames function not implemented");
+ }
+
+ // Expects a bed file(or url) and a chunk name
+ // Attempts to download tracks associated with the chunk name from the bed file if it is a URL
+ // Returns object with key: tracks
+ // Returns tracks found from local directories as a tracks object
+ async getChunkTracks(bedFile, chunk) {
+ throw new Error("getChunkTracks function not implemented");
+ }
+}
+
+export default APIInterface;
\ No newline at end of file
diff --git a/src/App.js b/src/App.js
index 8e24a5d0..d46f0dc5 100644
--- a/src/App.js
+++ b/src/App.js
@@ -14,6 +14,7 @@ import Footer from "./components/Footer";
import { dataOriginTypes } from "./enums";
import "./config-client.js";
import { config } from "./config-global.mjs";
+import ServerAPI from "./ServerAPI.mjs";
const EXAMPLE_TRACKS = [
// Fake tracks for the generated examples.
@@ -46,6 +47,8 @@ class App extends Component {
constructor(props) {
super(props);
+ this.APIInterface = new ServerAPI(props.apiUrl);
+
console.log('App component starting up with API URL: ' + props.apiUrl)
// Set defaultViewTarget to either URL params (if present) or the first example
@@ -186,12 +189,14 @@ class App extends Component {
apiUrl={this.props.apiUrl}
defaultViewTarget={this.defaultViewTarget}
getCurrentViewTarget={this.getCurrentViewTarget}
+ APIInterface={this.APIInterface}
/>
{
+ // This dispatcher will replace fetchAndParse when we or anyone eles imports it.
+ function fetchAndParseDispatcher() {
+ // Ge tthe real fetchAndParse
+ const { fetchAndParse } = jest.requireActual("./fetchAndParse");
+ // Grab the replacement or the real one if no replacement is set
+ let functionToUse = globalThis["__App.test.js_fetchAndParse_mock"] ?? fetchAndParse;
+ // Give it any arguments we got and return its return value.
+ return functionToUse.apply(this, arguments);
+ };
+ // When someone asks for this module, hand them these contents instead.
+ return {
+ __esModule: true,
+ fetchAndParse: fetchAndParseDispatcher
+ };
+});
+
+// TODO: We won't need to do *any* of this if we actually get the ability to pass an API implementation into the app.
beforeEach(() => {
jest.resetAllMocks();
+ clearFetchAndParseMock();
});
const getRegionInput = () => {
@@ -23,15 +59,17 @@ it("renders without crashing", () => {
});
it("renders with error when api call to server throws", async () => {
- fetchAndParseModule.fetchAndParse = () => {
+ setFetchAndParseMock(() => {
throw new Error("Mock Server Error");
- };
+ });
render();
- expect(screen.getAllByText(/Mock Server Error/i)[0]).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getAllByText(/Mock Server Error/i)[0]).toBeInTheDocument();
+ });
});
it("renders without crashing when sent bad fetch data from server", async () => {
- fetchAndParseModule.fetchAndParse = () => ({});
+ setFetchAndParseMock(() => ({}));
render();
await waitFor(() => {
diff --git a/src/ServerAPI.mjs b/src/ServerAPI.mjs
new file mode 100644
index 00000000..cf7467ef
--- /dev/null
+++ b/src/ServerAPI.mjs
@@ -0,0 +1,72 @@
+import { fetchAndParse } from "./fetchAndParse.js";
+import { APIInterface } from "./APIInterface.mjs";
+
+export class ServerAPI extends APIInterface {
+ constructor(apiUrl) {
+ super();
+ this.apiUrl = apiUrl;
+ }
+
+ // Each function takes a cancelSignal to cancel the fetch request if we will unmount component
+
+ async getChunkedData(viewTarget, cancelSignal) {
+ const json = await fetchAndParse(`${this.apiUrl}/getChunkedData`, {
+ signal: cancelSignal,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(viewTarget),
+ });
+ return json;
+ }
+
+ async getFilenames(cancelSignal) {
+ const json = await fetchAndParse(`${this.apiUrl}/getFilenames`, {
+ signal: cancelSignal,
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ return json;
+ }
+
+ async getBedRegions(bedFile, cancelSignal) {
+ const json = await fetchAndParse(`${this.apiUrl}/getBedRegions`, {
+ signal: cancelSignal,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ bedFile }),
+ });
+ return json;
+ }
+
+ async getPathNames(graphFile, cancelSignal) {
+ const json = await fetchAndParse(`${this.apiUrl}/getPathNames`, {
+ signal: cancelSignal,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ graphFile }),
+ });
+ return json
+ }
+
+ async getChunkTracks(bedFile, chunk, cancelSignal) {
+ const json = await fetchAndParse(`${this.apiUrl}/getChunkTracks`, {
+ signal: cancelSignal,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ bedFile: bedFile, chunk: chunk }),
+ });
+ return json;
+ }
+}
+
+export default ServerAPI;
\ No newline at end of file
diff --git a/src/components/HeaderForm.js b/src/components/HeaderForm.js
index 8c094cab..f52b2b7e 100644
--- a/src/components/HeaderForm.js
+++ b/src/components/HeaderForm.js
@@ -2,7 +2,6 @@ import React, { Component } from "react";
import PropTypes from "prop-types";
import { Container, Row, Col, Label, Alert, Button } from "reactstrap";
import { dataOriginTypes } from "../enums";
-import { fetchAndParse } from "../fetchAndParse";
import "../config-client.js";
import { config } from "../config-global.mjs";
import DataPositionFormRow from "./DataPositionFormRow";
@@ -172,6 +171,7 @@ class HeaderForm extends Component {
componentDidMount() {
this.fetchCanceler = new AbortController();
this.cancelSignal = this.fetchCanceler.signal;
+ this.api = this.props.APIInterface;
this.initState();
this.getMountedFilenames();
this.setUpWebsocket();
@@ -298,13 +298,7 @@ class HeaderForm extends Component {
getMountedFilenames = async () => {
this.setState({ error: null });
try {
- const json = await fetchAndParse(`${this.props.apiUrl}/getFilenames`, {
- signal: this.cancelSignal, // (so we can cancel the fetch request if we will unmount component)
- method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
- });
+ const json = await this.api.getFilenames(this.cancelSignal);
if (!json.files || json.files.length === 0) {
// We did not get back a graph, only (possibly) an error.
const error =
@@ -355,14 +349,7 @@ class HeaderForm extends Component {
getBedRegions = async (bedFile) => {
this.setState({ error: null });
try {
- const json = await fetchAndParse(`${this.props.apiUrl}/getBedRegions`, {
- signal: this.cancelSignal, // (so we can cancel the fetch request if we will unmount component)
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ bedFile }),
- });
+ const json = await this.api.getBedRegions(bedFile, this.cancelSignal);
// We need to do all our parsing here, if we expect the catch to catch errors.
if (!json.bedRegions || !(json.bedRegions["desc"] instanceof Array)) {
throw new Error(
@@ -392,14 +379,7 @@ class HeaderForm extends Component {
getPathNames = async (graphFile) => {
this.setState({ error: null });
try {
- const json = await fetchAndParse(`${this.props.apiUrl}/getPathNames`, {
- signal: this.cancelSignal, // (so we can cancel the fetch request if we will unmount component)
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ graphFile }),
- });
+ const json = await this.api.getPathNames(graphFile, this.cancelSignal);
// We need to do all our parsing here, if we expect the catch to catch errors.
let pathNames = json.pathNames;
if (!(pathNames instanceof Array)) {
@@ -565,13 +545,7 @@ class HeaderForm extends Component {
console.log("New tracks have been applied");
} else if (this.state.bedFile && chunk) {
// Try to retrieve tracks from the server
- const json = await fetchAndParse(`${this.props.apiUrl}/getChunkTracks`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ bedFile: this.state.bedFile, chunk: chunk }),
- });
+ const json = await this.api.getChunkTracks(this.state.bedFile, chunk, this.cancelSignal);
// Replace tracks if request returns non-falsey value
if (json.tracks) {
@@ -912,6 +886,7 @@ HeaderForm.propTypes = {
setDataOrigin: PropTypes.func.isRequired,
setCurrentViewTarget: PropTypes.func.isRequired,
defaultViewTarget: PropTypes.any, // Header Form State, may be null if no params in URL. see Types.ts
+ APIInterface: PropTypes.object.isRequired,
};
export default HeaderForm;
diff --git a/src/components/TubeMapContainer.js b/src/components/TubeMapContainer.js
index 85d48374..49bf4f6c 100644
--- a/src/components/TubeMapContainer.js
+++ b/src/components/TubeMapContainer.js
@@ -6,7 +6,6 @@ import isEqual from "react-fast-compare";
import TubeMap from "./TubeMap";
import * as tubeMap from "../util/tubemap";
import { dataOriginTypes } from "../enums";
-import { fetchAndParse } from "../fetchAndParse";
import PopUpInfoDialog from "./PopUpInfoDialog";
@@ -20,6 +19,7 @@ class TubeMapContainer extends Component {
componentDidMount() {
this.fetchCanceler = new AbortController();
this.cancelSignal = this.fetchCanceler.signal;
+ this.api = this.props.APIInterface;
this.getRemoteTubeMapData();
}
@@ -129,14 +129,7 @@ class TubeMapContainer extends Component {
getRemoteTubeMapData = async () => {
this.setState({ isLoading: true, error: null });
try {
- const json = await fetchAndParse(`${this.props.apiUrl}/getChunkedData`, {
- signal: this.cancelSignal, // (so we can cancel the fetch request if we will unmount component)
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(this.props.viewTarget),
- });
+ const json = await this.api.getChunkedData(this.props.viewTarget, this.cancelSignal);
if (json.graph === undefined) {
// We did not get back a graph, even if we didn't get an error either.
const error = "Fetching remote data returned error";
@@ -284,6 +277,7 @@ TubeMapContainer.propTypes = {
dataOrigin: PropTypes.oneOf(Object.values(dataOriginTypes)).isRequired,
viewTarget: PropTypes.object.isRequired,
visOptions: PropTypes.object.isRequired,
+ APIInterface: PropTypes.object.isRequired,
};
export default TubeMapContainer;