Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement API Interface #381

Merged
merged 5 commits into from
Jan 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Loading