/** * Copyright (c) Facebook, Inc. and its affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import fs from 'fs'; // eslint-disable-next-line import electron, {OpenDialogOptions, remote} from 'electron'; import {Atom, DataTableManager} from 'flipper-plugin'; import {createContext} from 'react'; import {Header, Request} from '../types'; import {bodyAsString, decodeBody} from '../utils'; import {message} from 'antd'; export type Route = { requestUrl: string; requestMethod: string; responseData: string; responseHeaders: {[id: string]: Header}; responseStatus: string; enabled: boolean; }; export type MockRoute = { requestUrl: string; method: string; data: string; headers: Header[]; status: string; enabled: boolean; }; export interface NetworkRouteManager { addRoute(): string | undefined; modifyRoute(id: string, routeChange: Partial): void; removeRoute(id: string): void; enableRoute(id: string): void; copyHighlightedCalls(): void; importRoutes(): void; exportRoutes(): void; clearRoutes(): void; } export const nullNetworkRouteManager: NetworkRouteManager = { addRoute(): string | undefined { return ''; }, modifyRoute(_id: string, _routeChange: Partial) {}, removeRoute(_id: string) {}, enableRoute(_id: string) {}, copyHighlightedCalls() {}, importRoutes() {}, exportRoutes() {}, clearRoutes() {}, }; export const NetworkRouteContext = createContext( nullNetworkRouteManager, ); export function createNetworkManager( nextRouteId: Atom, routes: Atom<{[id: string]: any}>, informClientMockChange: (routes: {[id: string]: any}) => Promise, tableManagerRef: React.RefObject | undefined>, ): NetworkRouteManager { return { addRoute(): string | undefined { const newNextRouteId = nextRouteId.get(); routes.update((draft) => { draft[newNextRouteId.toString()] = { requestUrl: '', requestMethod: 'GET', responseData: '', responseHeaders: {}, responseStatus: '200', enabled: true, }; }); nextRouteId.set(newNextRouteId + 1); return String(newNextRouteId); }, modifyRoute(id: string, routeChange: Partial) { if (!routes.get().hasOwnProperty(id)) { return; } routes.update((draft) => { Object.assign(draft[id], routeChange); }); informClientMockChange(routes.get()); }, removeRoute(id: string) { if (routes.get().hasOwnProperty(id)) { routes.update((draft) => { delete draft[id]; }); } informClientMockChange(routes.get()); }, enableRoute(id: string) { if (routes.get().hasOwnProperty(id)) { routes.update((draft) => { draft[id].enabled = !draft[id].enabled; }); } informClientMockChange(routes.get()); }, copyHighlightedCalls() { tableManagerRef.current?.getSelectedItems().forEach((request) => { // convert headers const headers: {[id: string]: Header} = {}; request.responseHeaders?.forEach((e) => { headers[e.key] = e; }); // convert data TODO: we only want this for non-binary data! See D23403095 const responseData = request && request.responseData ? decodeBody( request.responseHeaders ?? [], bodyAsString(request.responseData), ) : ''; const newNextRouteId = nextRouteId.get(); routes.update((draft) => { draft[newNextRouteId.toString()] = { requestUrl: request.url, requestMethod: request.method, responseData: responseData as string, responseHeaders: headers, responseStatus: request.status?.toString() ?? '', enabled: true, }; }); nextRouteId.set(newNextRouteId + 1); }); informClientMockChange(routes.get()); }, importRoutes() { const options: OpenDialogOptions = { properties: ['openFile'], filters: [{extensions: ['json'], name: 'Flipper Route Files'}], }; remote.dialog.showOpenDialog(options).then((result) => { const filePaths = result.filePaths; if (filePaths.length > 0) { fs.readFile(filePaths[0], 'utf8', (err, data) => { if (err) { message.error('Unable to import file'); return; } const importedRoutes = JSON.parse(data); importedRoutes?.forEach((importedRoute: Route) => { if (importedRoute != null) { const newNextRouteId = nextRouteId.get(); routes.update((draft) => { draft[newNextRouteId.toString()] = { requestUrl: importedRoute.requestUrl, requestMethod: importedRoute.requestMethod, responseData: importedRoute.responseData as string, responseHeaders: importedRoute.responseHeaders, responseStatus: importedRoute.responseStatus, enabled: true, }; }); nextRouteId.set(newNextRouteId + 1); } }); informClientMockChange(routes.get()); }); } }); }, exportRoutes() { remote.dialog .showSaveDialog( // @ts-ignore This appears to work but isn't allowed by the types null, { title: 'Export Routes', defaultPath: 'NetworkPluginRoutesExport.json', }, ) .then((result: electron.SaveDialogReturnValue) => { const file = result.filePath; if (!file) { return; } fs.writeFile( file, JSON.stringify(Object.values(routes.get()), null, 2), 'utf8', (err) => { if (err) { message.error('Failed to store mock routes: ' + err); } else { message.info('Successfully exported mock routes'); } }, ); }); }, clearRoutes() { routes.set({}); informClientMockChange(routes.get()); }, }; } export function computeMockRoutes(routes: {[id: string]: Route}) { const existedIdSet: {[id: string]: {[method: string]: boolean}} = {}; const filteredRoutes: {[id: string]: Route} = Object.entries(routes).reduce( (accRoutes, [id, route]) => { if (existedIdSet.hasOwnProperty(route.requestUrl)) { if ( existedIdSet[route.requestUrl].hasOwnProperty(route.requestMethod) ) { return accRoutes; } existedIdSet[route.requestUrl] = { ...existedIdSet[route.requestUrl], [route.requestMethod]: true, }; return Object.assign({[id]: route}, accRoutes); } else { existedIdSet[route.requestUrl] = { [route.requestMethod]: true, }; return Object.assign({[id]: route}, accRoutes); } }, {}, ); return filteredRoutes; }