Skip to content

Commit

Permalink
Merge pull request #381 from ducku/issues/377-refactor-api-interface
Browse files Browse the repository at this point in the history
Implement API Interface
  • Loading branch information
adamnovak authored Jan 8, 2024
2 parents 2ac1fb3 + b86362b commit 854896f
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 47 deletions.
42 changes: 42 additions & 0 deletions src/APIInterface.mjs
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -186,12 +189,14 @@ class App extends Component {
apiUrl={this.props.apiUrl}
defaultViewTarget={this.defaultViewTarget}
getCurrentViewTarget={this.getCurrentViewTarget}
APIInterface={this.APIInterface}
/>
<TubeMapContainer
viewTarget={this.state.viewTarget}
dataOrigin={this.state.dataOrigin}
apiUrl={this.props.apiUrl}
visOptions={this.state.visOptions}
APIInterface={this.APIInterface}
/>
<CustomizationAccordion
visOptions={this.state.visOptions}
Expand Down
52 changes: 45 additions & 7 deletions src/App.test.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,52 @@
// Tests functionality without server

import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import userEvent from "@testing-library/user-event";
import App from "./App";

import * as fetchAndParseModule from "./fetchAndParse";
// Tests functionality without server
import { fetchAndParse } from "./fetchAndParse";


jest.mock("./fetchAndParse");
// We want to be able to replace the `fetchAndParse` that *other* files see,
// and we want to use *different* implementations for different tests in this
// file. We can mock it with Jest, but Jest will move this call before the
// imports when runnin the tests, so we can't access any file-level variables
// in it. So we need to do some sneaky global trickery.

// Register the given replacement function to be called instead of fetchAndParse.
function setFetchAndParseMock(replacement) {
globalThis["__App.test.js_fetchAndParse_mock"] = replacement
}

// Remove any replacement function and go back to the real fetchAndParse.
function clearFetchAndParseMock() {
globalThis["__App.test.js_fetchAndParse_mock"] = undefined
}

jest.mock("./fetchAndParse", () => {
// 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 = () => {
Expand All @@ -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(<App />);
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(<App />);

await waitFor(() => {
Expand Down
72 changes: 72 additions & 0 deletions src/ServerAPI.mjs
Original file line number Diff line number Diff line change
@@ -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;
37 changes: 6 additions & 31 deletions src/components/HeaderForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
12 changes: 3 additions & 9 deletions src/components/TubeMapContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";


Expand All @@ -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();
}

Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;

0 comments on commit 854896f

Please sign in to comment.