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;