From fd384794f34d1f56e188d9a9d61a7c6d741df582 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Tue, 7 Jan 2025 17:22:24 -0600 Subject: [PATCH] Improve default connection logic (#734) * Cretae type NeptuneServiceType * Move default config logic in to AppStatusLoader * Update changelog * Add some comments * Add test for invalid URLs * Rename config * Improve formatting of validation errors --- Changelog.md | 2 + .../src/core/AppStatusLoader.tsx | 58 +++++++--- .../ConnectedProvider/ConnectedProvider.tsx | 16 +-- .../src/core/defaultConnection.test.ts | 97 +++++++++++++++++ .../src/core/defaultConnection.ts | 102 ++++++++++++++++++ packages/graph-explorer/src/index.tsx | 85 +-------------- .../CreateConnection/CreateConnection.tsx | 8 +- .../src/utils/testing/randomData.ts | 27 +++-- packages/shared/src/types/index.ts | 8 +- 9 files changed, 283 insertions(+), 120 deletions(-) create mode 100644 packages/graph-explorer/src/core/defaultConnection.test.ts create mode 100644 packages/graph-explorer/src/core/defaultConnection.ts diff --git a/Changelog.md b/Changelog.md index 5d758bec6..9d1cbcaae 100644 --- a/Changelog.md +++ b/Changelog.md @@ -11,6 +11,8 @@ connection handling, and Neptune error handling. ([#723](https://github.com/aws/graph-explorer/pull/723)) - **Improved** error logs in the browser console ([#721](https://github.com/aws/graph-explorer/pull/721)) +- **Improved** default connection handling, including fallbacks for invalid data + ([#734](https://github.com/aws/graph-explorer/pull/734)) - **Updated** dependencies ([#718](https://github.com/aws/graph-explorer/pull/718), [#720](https://github.com/aws/graph-explorer/pull/720)) diff --git a/packages/graph-explorer/src/core/AppStatusLoader.tsx b/packages/graph-explorer/src/core/AppStatusLoader.tsx index d250ab501..b7f617718 100644 --- a/packages/graph-explorer/src/core/AppStatusLoader.tsx +++ b/packages/graph-explorer/src/core/AppStatusLoader.tsx @@ -13,15 +13,10 @@ import { schemaAtom } from "./StateProvider/schema"; import useLoadStore from "./StateProvider/useLoadStore"; import { CONNECTIONS_OP } from "@/modules/CreateConnection/CreateConnection"; import { logger } from "@/utils"; +import { useQuery } from "@tanstack/react-query"; +import { fetchDefaultConnection } from "./defaultConnection"; -export type AppLoadingProps = { - config?: RawConfiguration; -}; - -const AppStatusLoader = ({ - config, - children, -}: PropsWithChildren) => { +const AppStatusLoader = ({ children }: PropsWithChildren) => { const location = useLocation(); useLoadStore(); const isStoreLoaded = useRecoilValue(isStoreLoadedAtom); @@ -31,6 +26,16 @@ const AppStatusLoader = ({ const [configuration, setConfiguration] = useRecoilState(configurationAtom); const schema = useRecoilValue(schemaAtom); + const defaultConfigQuery = useQuery({ + queryKey: ["default-connection"], + queryFn: fetchDefaultConnection, + staleTime: Infinity, + // Run the query only if the store is loaded and there are no configs + enabled: isStoreLoaded && configuration.size === 0, + }); + + const defaultConnectionConfig = defaultConfigQuery.data; + useEffect(() => { if (!isStoreLoaded) { logger.debug("Store not loaded, skipping config load"); @@ -44,16 +49,19 @@ const AppStatusLoader = ({ // If the config file is not in the store, // update configuration with the config file - if (!!config && !configuration.get(config.id)) { - const newConfig: RawConfiguration = config; + if ( + !!defaultConnectionConfig && + !configuration.get(defaultConnectionConfig.id) + ) { + const newConfig: RawConfiguration = defaultConnectionConfig; newConfig.__fileBase = true; - let activeConfigId = config.id; + let activeConfigId = defaultConnectionConfig.id; logger.debug("Adding new config to store", newConfig); setConfiguration(prevConfigMap => { const updatedConfig = new Map(prevConfigMap); if (newConfig.connection?.queryEngine) { - updatedConfig.set(config.id, newConfig); + updatedConfig.set(defaultConnectionConfig.id, newConfig); } //Set a configuration for each connection if queryEngine is not set if (!newConfig.connection?.queryEngine) { @@ -78,13 +86,19 @@ const AppStatusLoader = ({ // If the config file is stored, // only activate the configuration - if (!!config && configuration.get(config.id)) { - logger.debug("Config exists in store, activating", config.id); - setActiveConfig(config.id); + if ( + !!defaultConnectionConfig && + configuration.get(defaultConnectionConfig.id) + ) { + logger.debug( + "Config exists in store, activating", + defaultConnectionConfig.id + ); + setActiveConfig(defaultConnectionConfig.id); } }, [ activeConfig, - config, + defaultConnectionConfig, configuration, isStoreLoaded, setActiveConfig, @@ -102,8 +116,18 @@ const AppStatusLoader = ({ ); } + if (configuration.size === 0 && defaultConfigQuery.isLoading) { + return ( + } + /> + ); + } + // Loading from config file if exists - if (configuration.size === 0 && !!config) { + if (configuration.size === 0 && !!defaultConnectionConfig) { return ( 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000); } @@ -32,10 +27,7 @@ const queryClient = new QueryClient({ }, }); -const ConnectedProvider = ( - props: PropsWithChildren -) => { - const { config, children } = props; +export default function ConnectedProvider({ children }: PropsWithChildren) { return ( @@ -45,7 +37,7 @@ const ConnectedProvider = ( - + {children} @@ -57,6 +49,4 @@ const ConnectedProvider = ( ); -}; - -export default ConnectedProvider; +} diff --git a/packages/graph-explorer/src/core/defaultConnection.test.ts b/packages/graph-explorer/src/core/defaultConnection.test.ts new file mode 100644 index 000000000..02de00cd7 --- /dev/null +++ b/packages/graph-explorer/src/core/defaultConnection.test.ts @@ -0,0 +1,97 @@ +import { + createRandomBoolean, + createRandomInteger, + createRandomName, + createRandomUrlString, +} from "@shared/utils/testing"; +import { + DefaultConnectionDataSchema, + mapToConnection, +} from "./defaultConnection"; +import { + createRandomAwsRegion, + createRandomQueryEngine, + createRandomServiceType, +} from "@/utils/testing"; + +describe("mapToConnection", () => { + test("should map default connection data to connection config", () => { + const defaultConnectionData = createRandomDefaultConnectionData(); + const actual = mapToConnection(defaultConnectionData); + expect(actual).toEqual({ + id: "Default Connection", + displayLabel: "Default Connection", + connection: { + graphDbUrl: defaultConnectionData.GRAPH_EXP_CONNECTION_URL, + url: defaultConnectionData.GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT, + proxyConnection: defaultConnectionData.GRAPH_EXP_USING_PROXY_SERVER, + queryEngine: defaultConnectionData.GRAPH_EXP_GRAPH_TYPE, + awsAuthEnabled: defaultConnectionData.GRAPH_EXP_IAM, + awsRegion: defaultConnectionData.GRAPH_EXP_AWS_REGION, + serviceType: defaultConnectionData.GRAPH_EXP_SERVICE_TYPE, + fetchTimeoutMs: defaultConnectionData.GRAPH_EXP_FETCH_REQUEST_TIMEOUT, + nodeExpansionLimit: + defaultConnectionData.GRAPH_EXP_NODE_EXPANSION_LIMIT, + }, + }); + }); +}); + +describe("DefaultConnectionDataSchema", () => { + test("should parse default connection data", () => { + const data = createRandomDefaultConnectionData(); + const actual = DefaultConnectionDataSchema.parse(data); + expect(actual).toEqual(data); + }); + + test("should handle missing values", () => { + const data = {}; + const actual = DefaultConnectionDataSchema.parse(data); + expect(actual).toEqual({ + GRAPH_EXP_USING_PROXY_SERVER: false, + GRAPH_EXP_CONNECTION_URL: "", + GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT: "", + GRAPH_EXP_IAM: false, + GRAPH_EXP_AWS_REGION: "", + GRAPH_EXP_SERVICE_TYPE: "neptune-db", + GRAPH_EXP_FETCH_REQUEST_TIMEOUT: 240000, + }); + }); + + test("should handle invalid service type", () => { + const data: any = createRandomDefaultConnectionData(); + data.GRAPH_EXP_SERVICE_TYPE = createRandomName("serviceType"); + // Make the enum less strict + const actual = DefaultConnectionDataSchema.parse(data); + expect(actual).toEqual({ ...data, GRAPH_EXP_SERVICE_TYPE: "neptune-db" }); + }); + + test("should handle invalid URLs", () => { + const data: any = createRandomDefaultConnectionData(); + data.GRAPH_EXP_CONNECTION_URL = createRandomName("connectionURL"); + data.GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT = createRandomName( + "publicOrProxyEndpoint" + ); + // Make the enum less strict + const actual = DefaultConnectionDataSchema.parse(data); + expect(actual).toEqual({ + ...data, + GRAPH_EXP_CONNECTION_URL: "", + GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT: "", + }); + }); +}); + +function createRandomDefaultConnectionData() { + return { + GRAPH_EXP_USING_PROXY_SERVER: createRandomBoolean(), + GRAPH_EXP_CONNECTION_URL: createRandomUrlString(), + GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT: createRandomUrlString(), + GRAPH_EXP_GRAPH_TYPE: createRandomQueryEngine(), + GRAPH_EXP_IAM: createRandomBoolean(), + GRAPH_EXP_AWS_REGION: createRandomAwsRegion(), + GRAPH_EXP_SERVICE_TYPE: createRandomServiceType(), + GRAPH_EXP_FETCH_REQUEST_TIMEOUT: createRandomInteger(), + GRAPH_EXP_NODE_EXPANSION_LIMIT: createRandomInteger(), + }; +} diff --git a/packages/graph-explorer/src/core/defaultConnection.ts b/packages/graph-explorer/src/core/defaultConnection.ts new file mode 100644 index 000000000..3b9b1f04f --- /dev/null +++ b/packages/graph-explorer/src/core/defaultConnection.ts @@ -0,0 +1,102 @@ +import { logger, DEFAULT_SERVICE_TYPE } from "@/utils"; +import { queryEngineOptions, neptuneServiceTypeOptions } from "@shared/types"; +import { z } from "zod"; +import { RawConfiguration } from "./ConfigurationProvider"; + +export const DefaultConnectionDataSchema = z.object({ + // Connection info + GRAPH_EXP_USING_PROXY_SERVER: z.boolean().default(false), + GRAPH_EXP_CONNECTION_URL: z.string().url().catch(""), + GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT: z.string().url().catch(""), + GRAPH_EXP_GRAPH_TYPE: z.enum(queryEngineOptions).optional(), + // IAM auth info + GRAPH_EXP_IAM: z.boolean().default(false), + GRAPH_EXP_AWS_REGION: z.string().optional().default(""), + GRAPH_EXP_SERVICE_TYPE: z + .enum(neptuneServiceTypeOptions) + .default(DEFAULT_SERVICE_TYPE) + .catch(DEFAULT_SERVICE_TYPE), + // Connection options + GRAPH_EXP_FETCH_REQUEST_TIMEOUT: z.number().default(240000), + GRAPH_EXP_NODE_EXPANSION_LIMIT: z.number().optional(), +}); + +export type DefaultConnectionData = z.infer; + +/** Fetches the default connection from multiple possible locations and returns null on failure. */ +export async function fetchDefaultConnection(): Promise { + const defaultConnectionPath = `${location.origin}/defaultConnection`; + const sagemakerConnectionPath = `${location.origin}/proxy/9250/defaultConnection`; + + try { + const defaultConnection = + (await fetchDefaultConnectionFor(defaultConnectionPath)) ?? + (await fetchDefaultConnectionFor(sagemakerConnectionPath)); + if (!defaultConnection) { + logger.debug("No default connection found"); + return null; + } + const config = mapToConnection(defaultConnection); + logger.debug("Default connection created", config); + + return config; + } catch (error) { + logger.error( + `Error when trying to create connection: ${error instanceof Error ? error.message : "Unexpected error"}` + ); + return null; + } +} + +/** Attempts to fetch a default connection from the given URL and returns null on a failure. */ +export async function fetchDefaultConnectionFor( + url: string +): Promise { + try { + logger.debug("Fetching default connection from", url); + const response = await fetch(url); + if (!response.ok) { + const responseText = await response.text(); + logger.warn( + `Response status ${response.status} for default connection url`, + url, + responseText + ); + return null; + } + const data = await response.json(); + logger.debug("Default connection data for url", url, data); + const result = DefaultConnectionDataSchema.safeParse(data); + if (result.success) { + return result.data; + } else { + logger.warn( + "Failed to parse default connection data", + result.error.flatten() + ); + return null; + } + } catch (error) { + logger.warn("Failed to fetch default connection for path", url, error); + return null; + } +} + +export function mapToConnection(data: DefaultConnectionData): RawConfiguration { + const config: RawConfiguration = { + id: "Default Connection", + displayLabel: "Default Connection", + connection: { + url: data.GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT, + queryEngine: data.GRAPH_EXP_GRAPH_TYPE, + proxyConnection: data.GRAPH_EXP_USING_PROXY_SERVER, + graphDbUrl: data.GRAPH_EXP_CONNECTION_URL, + awsAuthEnabled: data.GRAPH_EXP_IAM, + awsRegion: data.GRAPH_EXP_AWS_REGION, + serviceType: data.GRAPH_EXP_SERVICE_TYPE, + fetchTimeoutMs: data.GRAPH_EXP_FETCH_REQUEST_TIMEOUT, + nodeExpansionLimit: data.GRAPH_EXP_NODE_EXPANSION_LIMIT, + }, + }; + return config; +} diff --git a/packages/graph-explorer/src/index.tsx b/packages/graph-explorer/src/index.tsx index 9c578fd7f..44b47ac48 100644 --- a/packages/graph-explorer/src/index.tsx +++ b/packages/graph-explorer/src/index.tsx @@ -1,95 +1,18 @@ -import React, { useEffect, useState } from "react"; +import React from "react"; import { createRoot } from "react-dom/client"; import { HashRouter as Router } from "react-router"; import App from "./App"; -import { RawConfiguration } from "./core"; import ConnectedProvider from "./core/ConnectedProvider"; -import "./index.css"; -import "@mantine/core/styles.css"; -import { DEFAULT_SERVICE_TYPE } from "./utils/constants"; import "core-js/full/iterator"; -import { logger } from "./utils"; - -const grabConfig = async (): Promise => { - const defaultConnectionPath = `${location.origin}/defaultConnection`; - const sagemakerConnectionPath = `${location.origin}/proxy/9250/defaultConnection`; - let defaultConnectionFile; - - try { - logger.debug( - "Attempting to find default connection file at", - defaultConnectionPath - ); - defaultConnectionFile = await fetch(defaultConnectionPath); - - if (!defaultConnectionFile.ok) { - logger.debug( - `Failed to find default connection file at .../defaultConnection, trying path for Sagemaker.`, - sagemakerConnectionPath - ); - defaultConnectionFile = await fetch(sagemakerConnectionPath); - if (defaultConnectionFile.ok) { - logger.log( - `Found default connection file at ../proxy/9250/defaultConnection.` - ); - } else { - logger.debug( - `Did not find default connection file at ../proxy/9250/defaultConnection. No defaultConnectionFile will be set.` - ); - } - } else { - logger.log(`Found default connection file at ../defaultConnection.`); - } - - const contentType = defaultConnectionFile.headers.get("content-type"); - if (!contentType || !contentType.includes("application/json")) { - logger.debug(`Default config response is not JSON`); - return; - } - const defaultConnectionData = await defaultConnectionFile.json(); - logger.debug("Default connection data", defaultConnectionData); - const config: RawConfiguration = { - id: "Default Connection", - displayLabel: "Default Connection", - connection: { - url: defaultConnectionData.GRAPH_EXP_PUBLIC_OR_PROXY_ENDPOINT || "", - queryEngine: defaultConnectionData.GRAPH_EXP_GRAPH_TYPE, - proxyConnection: !!defaultConnectionData.GRAPH_EXP_USING_PROXY_SERVER, - graphDbUrl: defaultConnectionData.GRAPH_EXP_CONNECTION_URL || "", - awsAuthEnabled: !!defaultConnectionData.GRAPH_EXP_IAM, - awsRegion: defaultConnectionData.GRAPH_EXP_AWS_REGION || "", - serviceType: - defaultConnectionData.GRAPH_EXP_SERVICE_TYPE || DEFAULT_SERVICE_TYPE, - fetchTimeoutMs: - defaultConnectionData.GRAPH_EXP_FETCH_REQUEST_TIMEOUT || 240000, - nodeExpansionLimit: - defaultConnectionData.GRAPH_EXP_NODE_EXPANSION_LIMIT, - }, - }; - logger.debug("Default connection created", config); - return config; - } catch (error) { - console.error( - `Error when trying to create connection: ${error instanceof Error ? error.message : "Unexpected error"}` - ); - } -}; +import "./index.css"; +import "@mantine/core/styles.css"; const BootstrapApp = () => { - const [config, setConfig] = useState(undefined); - - useEffect(() => { - (async () => { - const config = await grabConfig(); - setConfig(config); - })(); - }, []); - return ( - + diff --git a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx index 728585113..267c09e4b 100644 --- a/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx +++ b/packages/graph-explorer/src/modules/CreateConnection/CreateConnection.tsx @@ -5,7 +5,11 @@ import { InfoTooltip, TextArea } from "@/components"; import Button from "@/components/Button"; import Input from "@/components/Input"; import Select from "@/components/Select"; -import { ConnectionConfig, QueryEngine } from "@shared/types"; +import { + ConnectionConfig, + QueryEngine, + NeptuneServiceType, +} from "@shared/types"; import { ConfigurationContextProps, RawConfiguration, @@ -32,7 +36,7 @@ type ConnectionForm = { proxyConnection?: boolean; graphDbUrl?: string; awsAuthEnabled?: boolean; - serviceType?: "neptune-db" | "neptune-graph"; + serviceType?: NeptuneServiceType; awsRegion?: string; fetchTimeoutEnabled: boolean; fetchTimeoutMs?: number; diff --git a/packages/graph-explorer/src/utils/testing/randomData.ts b/packages/graph-explorer/src/utils/testing/randomData.ts index a32d2d473..975262ae7 100644 --- a/packages/graph-explorer/src/utils/testing/randomData.ts +++ b/packages/graph-explorer/src/utils/testing/randomData.ts @@ -27,7 +27,12 @@ import { } from "@/core/StateProvider/userPreferences"; import { toNodeMap } from "@/core/StateProvider/nodes"; import { toEdgeMap } from "@/core/StateProvider/edges"; -import { queryEngineOptions } from "@shared/types"; +import { + NeptuneServiceType, + neptuneServiceTypeOptions, + QueryEngine, + queryEngineOptions, +} from "@shared/types"; /* @@ -205,10 +210,8 @@ export function createRandomRawConfiguration(): RawConfiguration { const isIamEnabled = createRandomBoolean(); const fetchTimeoutMs = randomlyUndefined(createRandomInteger()); const nodeExpansionLimit = randomlyUndefined(createRandomInteger()); - const serviceType = randomlyUndefined( - pickRandomElement(["neptune-db", "neptune-graph"] as const) - ); - const queryEngine = pickRandomElement([...queryEngineOptions]); + const serviceType = randomlyUndefined(createRandomServiceType()); + const queryEngine = createRandomQueryEngine(); return { id: createRandomName("id"), @@ -220,7 +223,7 @@ export function createRandomRawConfiguration(): RawConfiguration { proxyConnection: isProxyConnection, ...(isIamEnabled && { awsAuthEnabled: createRandomBoolean() }), ...(isIamEnabled && { - awsRegion: pickRandomElement(["us-west-1", "us-west-2", "us-east-1"]), + awsRegion: createRandomAwsRegion(), }), ...(fetchTimeoutMs && { fetchTimeoutMs }), ...(nodeExpansionLimit && { nodeExpansionLimit }), @@ -229,6 +232,18 @@ export function createRandomRawConfiguration(): RawConfiguration { }; } +export function createRandomQueryEngine(): QueryEngine { + return pickRandomElement([...queryEngineOptions]); +} + +export function createRandomServiceType(): NeptuneServiceType { + return pickRandomElement([...neptuneServiceTypeOptions]); +} + +export function createRandomAwsRegion(): string { + return pickRandomElement(["us-west-1", "us-west-2", "us-east-1"]); +} + export function createRandomVertexPreferences(): VertexPreferences { const color = randomlyUndefined(createRandomColor()); const borderColor = randomlyUndefined(createRandomColor()); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9f364e12a..bd13eddbb 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,6 +1,12 @@ export const queryEngineOptions = ["gremlin", "sparql", "openCypher"] as const; export type QueryEngine = (typeof queryEngineOptions)[number]; +export const neptuneServiceTypeOptions = [ + "neptune-db", + "neptune-graph", +] as const; +export type NeptuneServiceType = (typeof neptuneServiceTypeOptions)[number]; + export type ConnectionConfig = { /** * Base URL to access to the database through HTTPs endpoints @@ -27,7 +33,7 @@ export type ConnectionConfig = { /** * If it is Neptune, it could need authentication. */ - serviceType?: "neptune-db" | "neptune-graph"; + serviceType?: NeptuneServiceType; /** * AWS Region where the Neptune cluster is deployed. * It is needed to sign requests.