Skip to content

Commit

Permalink
Improve default connection logic (#734)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kmcginnes authored Jan 7, 2025
1 parent 18df1fe commit fd38479
Show file tree
Hide file tree
Showing 9 changed files with 283 additions and 120 deletions.
2 changes: 2 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
58 changes: 41 additions & 17 deletions packages/graph-explorer/src/core/AppStatusLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppLoadingProps>) => {
const AppStatusLoader = ({ children }: PropsWithChildren) => {
const location = useLocation();
useLoadStore();
const isStoreLoaded = useRecoilValue(isStoreLoadedAtom);
Expand All @@ -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");
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -102,8 +116,18 @@ const AppStatusLoader = ({
);
}

if (configuration.size === 0 && defaultConfigQuery.isLoading) {
return (
<PanelEmptyState
title="Loading default connection..."
subtitle="We are checking for a default connection"
icon={<LoadingSpinner />}
/>
);
}

// Loading from config file if exists
if (configuration.size === 0 && !!config) {
if (configuration.size === 0 && !!defaultConnectionConfig) {
return (
<PanelEmptyState
title="Reading configuration..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { NotificationProvider } from "@/components/NotificationProvider";
import Toast from "@/components/Toast";
import AppStatusLoader from "@/core/AppStatusLoader";
import type { RawConfiguration } from "@/core/ConfigurationProvider";
import StateProvider from "@/core/StateProvider/StateProvider";
import ThemeProvider from "@/core/ThemeProvider/ThemeProvider";
import { MantineProvider } from "@mantine/core";
Expand All @@ -13,10 +12,6 @@ import { ErrorBoundary } from "react-error-boundary";
import AppErrorPage from "@/core/AppErrorPage";
import { TooltipProvider } from "@/components";

export type ConnectedProviderProps = {
config?: RawConfiguration;
};

function exponentialBackoff(attempt: number): number {
return Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000);
}
Expand All @@ -32,10 +27,7 @@ const queryClient = new QueryClient({
},
});

const ConnectedProvider = (
props: PropsWithChildren<ConnectedProviderProps>
) => {
const { config, children } = props;
export default function ConnectedProvider({ children }: PropsWithChildren) {
return (
<ErrorBoundary FallbackComponent={AppErrorPage}>
<QueryClientProvider client={queryClient}>
Expand All @@ -45,7 +37,7 @@ const ConnectedProvider = (
<ThemeProvider>
<NotificationProvider component={Toast}>
<StateProvider>
<AppStatusLoader config={config}>
<AppStatusLoader>
<ExpandNodeProvider>{children}</ExpandNodeProvider>
</AppStatusLoader>
</StateProvider>
Expand All @@ -57,6 +49,4 @@ const ConnectedProvider = (
</QueryClientProvider>
</ErrorBoundary>
);
};

export default ConnectedProvider;
}
97 changes: 97 additions & 0 deletions packages/graph-explorer/src/core/defaultConnection.test.ts
Original file line number Diff line number Diff line change
@@ -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(),
};
}
102 changes: 102 additions & 0 deletions packages/graph-explorer/src/core/defaultConnection.ts
Original file line number Diff line number Diff line change
@@ -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<typeof DefaultConnectionDataSchema>;

/** Fetches the default connection from multiple possible locations and returns null on failure. */
export async function fetchDefaultConnection(): Promise<RawConfiguration | null> {
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<DefaultConnectionData | null> {
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;
}
Loading

0 comments on commit fd38479

Please sign in to comment.