add dynamic db routing storybook component

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8152
Co-authored-by: Varun Choudhary <68095256+Varun-Choudhary@users.noreply.github.com>
GitOrigin-RevId: 89a15b336d85301308e18e82754f10f4ed856563
This commit is contained in:
Daniele Cammareri 2023-03-07 17:32:03 +01:00 committed by hasura-bot
parent 061c6aa0f9
commit 0dc3073559
13 changed files with 811 additions and 34 deletions

View File

@ -0,0 +1,130 @@
import React from 'react';
import z from 'zod';
import { Collapsible } from '../../../../../../new-components/Collapsible';
import { Dialog } from '../../../../../../new-components/Dialog';
import { schema } from '../../schema';
import {
InputField,
useConsoleForm,
} from '../../../../../../new-components/Form';
import { areSSLSettingsEnabled } from '../../utils/helpers';
import { DatabaseUrl } from '../DatabaseUrl';
import { IsolationLevel } from '../IsolationLevel';
import { PoolSettings } from '../PoolSettings';
import { SslSettings } from '../SslSettings';
import { UsePreparedStatements } from '../UsePreparedStatements';
interface ConnectPostgresModalProps {
alreadyUseNames?: string[];
defaultValues?: z.infer<typeof schema>;
onClose: () => void;
onSubmit: (values: z.infer<typeof schema>) => void;
}
export const ConnectPostgresModal = (props: ConnectPostgresModalProps) => {
const { onClose, onSubmit, defaultValues, alreadyUseNames } = props;
const {
Form,
methods: { setError, formState, trigger, getValues },
} = useConsoleForm({
schema,
options: {
defaultValues: defaultValues ?? {
configuration: {
connectionInfo: {
databaseUrl: {
connectionType: 'databaseUrl',
},
},
},
},
},
});
return (
<Form
onSubmit={values => {
if (alreadyUseNames?.includes(values.name)) {
setError('name', {
type: 'manual',
message: 'This name is already in use',
});
} else {
onSubmit(values);
}
}}
>
<Dialog
size="lg"
hasBackdrop
title={defaultValues ? 'Edit connection' : 'Add connection'}
description={`${
defaultValues ? 'Edit connection' : 'Add connections'
} which will be available to be referenced in your dynamic connection template.`}
onClose={onClose}
>
<>
<div className="px-6 mb-8">
<InputField
name="name"
label="Connection name"
placeholder="Connection name"
/>
<div>
<DatabaseUrl
name="configuration.connectionInfo.databaseUrl"
hideOptions={[]}
/>
</div>
<div className="mt-sm">
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
Advanced Settings
</div>
}
>
<PoolSettings
name={`configuration.connectionInfo.poolSettings`}
/>
<IsolationLevel name={`configuration.connectionInfo`} />
<UsePreparedStatements name={`configuration.connectionInfo`} />
{areSSLSettingsEnabled() && (
<Collapsible
triggerChildren={
<div className="font-semibold text-muted">
SSL Certificates Settings
<span className="px-1.5 italic font-light">
(Certificates will be loaded from{' '}
<a href="https://hasura.io/docs/latest/graphql/cloud/projects/create.html#existing-database">
environment variables
</a>
)
</span>
</div>
}
>
<SslSettings
name={`configuration.connectionInfo.sslSettings`}
/>
</Collapsible>
)}
</Collapsible>
</div>
</div>
<Dialog.Footer
callToDeny="Cancel"
callToAction="Submit"
onClose={onClose}
onSubmit={() => {}}
/>
</>
</Dialog>
</Form>
);
};

View File

@ -0,0 +1,48 @@
import { expect } from '@storybook/jest';
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { handlers } from '../../../../../../mocks/metadata.mock';
import { ReactQueryDecorator } from '../../../../../../storybook/decorators/react-query';
import { DynamicDBRouting } from './DynamicDBRouting';
export default {
component: DynamicDBRouting,
decorators: [ReactQueryDecorator()],
parameters: {
msw: handlers({ delay: 500 }),
},
} as ComponentMeta<typeof DynamicDBRouting>;
export const Default: ComponentStory<typeof DynamicDBRouting> = () => (
<DynamicDBRouting sourceName="default" />
);
Default.play = async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(() => {
expect(canvas.getByLabelText('Database Tenancy')).toBeInTheDocument();
});
// click on Database Tenancy
const radioTenancy = canvas.getByLabelText('Database Tenancy');
userEvent.click(radioTenancy);
// click on "Add Connection"
const buttonAddConnection = canvas.getByText('Add Connection');
userEvent.click(buttonAddConnection);
// write "test" in the input text with testid "name"
const inputName = canvas.getByTestId('name');
userEvent.type(inputName, 'test');
// write "test" in the input text with testid "configuration.connectionInfo.databaseUrl.url"
const inputDatabaseUrl = canvas.getByTestId(
'configuration.connectionInfo.databaseUrl.url'
);
userEvent.type(inputDatabaseUrl, 'test');
// click on submit
const buttonSubmit = canvas.getByText('Submit');
userEvent.click(buttonSubmit);
};

View File

@ -0,0 +1,126 @@
import React from 'react';
import z from 'zod';
import { SimpleForm } from '../../../../../../new-components/Form';
import { ConnectPostgresModal } from './ConnectPostgresModal';
import { DynamicDBRoutingForm } from './DynamicDBRoutingForm';
import { generatePostgresRequestPayload } from '../../utils/generateRequests';
import { adaptPostgresConnectionInfo } from '../../utils/adaptResponse';
import { useDynamicDbRouting } from './hooks/useDynamicDbRouting';
import { ConnectionSet } from '../../../../../../metadata/types';
import { PostgresConfiguration } from '../../../../../hasura-metadata-types';
const schema = z.object({
connection_template: z.string().optional(),
});
interface DynamicDBRoutingProps {
sourceName: string;
}
export const DynamicDBRouting = (props: DynamicDBRoutingProps) => {
const {
connectionTemplate,
connectionSet,
addConnection,
removeConnection,
updateConnection,
updateConnectionTemplate,
isLoading,
isMetadaLoading,
} = useDynamicDbRouting({
sourceName: props.sourceName,
});
const [isModalOpen, setIsModalOpen] = React.useState(false);
const [editingConnectionSetMember, setEditingConnectionSetMember] =
React.useState<string>();
const connectionSetMembers = connectionSet.map(connection => {
const { name, connection_info } = connection;
return {
name,
configuration: {
connectionInfo: adaptPostgresConnectionInfo(
connection_info as PostgresConfiguration['connection_info']
),
},
};
});
if (isMetadaLoading) {
return null;
}
console.log('template', connectionTemplate);
return (
<>
{isModalOpen && (
<ConnectPostgresModal
alreadyUseNames={connectionSetMembers.map(
connection => connection.name
)}
onSubmit={values => {
const payload = {
name: values.name,
connection_info: generatePostgresRequestPayload({
driver: 'postgres',
values,
}).details.configuration.connection_info,
} as ConnectionSet;
addConnection(payload);
setIsModalOpen(false);
}}
onClose={() => setIsModalOpen(false)}
/>
)}
{editingConnectionSetMember && (
<ConnectPostgresModal
alreadyUseNames={connectionSetMembers.map(
connection => connection.name
)}
defaultValues={connectionSetMembers.find(
connection => connection.name === editingConnectionSetMember
)}
onSubmit={values => {
updateConnection(editingConnectionSetMember, {
name: values.name,
connection_info: generatePostgresRequestPayload({
driver: 'postgres',
values,
}).details.configuration.connection_info,
} as ConnectionSet);
setEditingConnectionSetMember(undefined);
}}
onClose={() => setEditingConnectionSetMember(undefined)}
/>
)}
<SimpleForm
onSubmit={values => {
updateConnectionTemplate(values.connection_template);
}}
schema={schema}
options={{
defaultValues: {
connection_template: connectionTemplate ?? undefined,
},
}}
>
<DynamicDBRoutingForm
connectionSetMembers={connectionSetMembers}
onAddConnection={() => setIsModalOpen(true)}
onEditConnection={connectionName => {
setEditingConnectionSetMember(connectionName);
}}
onRemoveConnection={connectionName => {
removeConnection(connectionName);
}}
isLoading={isLoading}
connectionTemplate={connectionTemplate}
/>
</SimpleForm>
</>
);
};

View File

@ -0,0 +1,251 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { FaExclamationCircle, FaPlusCircle } from 'react-icons/fa';
import z from 'zod';
import { Badge } from '../../../../../../new-components/Badge';
import { Button } from '../../../../../../new-components/Button';
import { CardedTable } from '../../../../../../new-components/CardedTable';
import { CardRadioGroup } from '../../../../../../new-components/CardRadioGroup';
import { schema as postgresSchema } from '../../schema';
import { CodeEditorField } from '../../../../../../new-components/Form';
import { LearnMoreLink } from '../../../../../../new-components/LearnMoreLink';
import { IconTooltip } from '../../../../../../new-components/Tooltip';
const editorOptions = {
minLines: 33,
maxLines: 33,
showLineNumbers: true,
useSoftTabs: true,
showPrintMargin: false,
showGutter: true,
wrap: true,
};
const templates = {
disabled: {
value: 'disabled',
title: 'Disabled',
body: 'Use default Hasura connection routing.',
template: '',
isSelected: (connectionTemplate?: string | null) => !connectionTemplate,
},
tenancy: {
value: 'tenancy',
title: 'Database Tenancy',
body: 'Tenancy template using a x-hasura-tenant variable to connect to a named database.',
template: `{{ if ($.request.session.x-hasura-tenant-id == "my_tenant_1")}}
{{$.connection_set.my_tenant_1_connection}}
{{ elif ($.request.session.x-hasura-tenant-id == "my_tenant_2")}}
{{$.connection_set.my_tenant_2_connection}}
{{ else }}
{{$.default}}
{{ end }}`,
isSelected: (connectionTemplate?: string | null) =>
connectionTemplate?.includes('x-hasura-tenant-id'),
},
'no-stale-reads': {
value: 'no-stale-reads',
title: 'Read Replicas - No Stale Reads',
body: 'No stale reads template using the primary for reads on mutations and replicas for all other reads.',
template: `{{ if (($.request.query.operation_type == "query")
|| ($.request.query.operation_type == "subscription"))
&& ($.request.headers.x-query-read-no-stale == "true") }}
{{$.primary}}
{{ else }}
{{$.default}}
{{ end }}`,
isSelected: (connectionTemplate?: string | null) =>
connectionTemplate?.includes('x-query-read-no-stale'),
},
sharding: {
value: 'sharding',
title: 'Different credentials',
body: 'Route specific queries to specific databases in a distributed database system model.',
template: `{{ if ($.request.session.x-hasura-role == "manager")}}
{{$.connection_set.manager_connection}}
{{ elif ($.request.session.x-hasura-role == "employee")}}
{{$.connection_set.employee_connection}}
{{ else }}
{{$.default}}
{{ end }}`,
isSelected: (connectionTemplate?: string | null) =>
connectionTemplate?.includes('x-hasura-role'),
},
custom: {
value: 'custom',
title: 'Custom Template',
body: 'Write a custom connection template using Kriti templating.',
template: `{{ if ()}}
{{$.}}
{{ elif ()}}
{{$.}}
{{ else }}
{{$.default}}
{{ end }}`,
isSelected: (connectionTemplate?: string | null) => !!connectionTemplate,
},
};
interface DynamicDBRoutingFormProps {
connectionSetMembers: z.infer<typeof postgresSchema>[];
onAddConnection: () => void;
onRemoveConnection: (name: string) => void;
onEditConnection: (name: string) => void;
isLoading: boolean;
connectionTemplate?: string | null;
}
export const DynamicDBRoutingForm = (props: DynamicDBRoutingFormProps) => {
const {
connectionSetMembers,
onAddConnection,
onRemoveConnection,
onEditConnection,
isLoading,
connectionTemplate,
} = props;
const { setValue } = useFormContext();
const [template, setTemplate] = React.useState<keyof typeof templates>(
(Object.entries(templates).find(([_, template]) =>
template.isSelected(connectionTemplate)
)?.[0] as keyof typeof templates) || 'disabled'
);
return (
<div>
<div>
<div className="mb-2">
{template !== 'disabled' && (
<div
className={`flex items-center rounded bg-gray-200 border border-gray-300 py-sm px-sm mb-md`}
>
<FaExclamationCircle className="fill-current self-start h-md text-muted" />
<div className="ml-xs">
<strong>Dynamic Database Routing Precedence</strong>
<p>
{' '}
Dynamic database routing takes precedence over read replicas.
You may use both read replica routing and default database
routing in your connection template.
</p>
</div>
</div>
)}
<div className="block flex items-center text-gray-600 font-semibold">
<label htmlFor="connection_template" className="font-semibold">
Connection Template
</label>
<IconTooltip message="Connection templates to route GraphQL requests based on different request parameters such as session variables, headers and tenant IDs." />
<LearnMoreLink
href="https://hasura.io/docs/latest/databases/connect-db/dynamic-db-connection/#connection-template"
className="font-normal"
/>
</div>
<div className="text-muted">
Database connection template to define dynamic connection routing.
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<CardRadioGroup
value={template}
orientation="vertical"
onChange={value => {
setTemplate(value as keyof typeof templates);
setValue(
'connection_template',
templates[value as keyof typeof templates]?.template
);
}}
items={Object.values(templates).map(template => ({
value: template.value,
title: template.title,
body: template.body,
}))}
/>
<div data-testid="template-editor">
<CodeEditorField
disabled={template === 'disabled'}
noErrorPlaceholder
name="connection_template"
editorOptions={editorOptions}
/>
</div>
</div>
</div>
<div className="mb-2 mt-8 flex justify-between items-end">
<div>
<div className="block flex items-center text-gray-600 font-semibold">
<label htmlFor="template" className="font-semibold">
Available Connections for Templating
</label>
<IconTooltip message="Available database connections which can be referenced in your dynamic connection template." />
<LearnMoreLink
href="https://hasura.io/docs/latest/databases/connect-db/dynamic-db-connection/#connection-set"
text="(Learn More)"
className="font-normal"
/>
</div>
<div className="text-muted">
Available connections which can be referenced in your dynamic
connection template.{' '}
</div>
</div>
<Button
onClick={onAddConnection}
icon={<FaPlusCircle />}
disabled={isLoading}
>
Add Connection
</Button>
</div>
<div>
<CardedTable
showActionCell
columns={['Connection']}
data={[
[
'{{$.default}}',
<Badge color="light-gray">Default Routing Behavior</Badge>,
],
[
'{{$.primary}}',
<Badge color="light-gray">The Database Primary</Badge>,
],
[
'{{$.read_replicas}}',
<Badge color="light-gray">Read Replica Routing</Badge>,
],
...connectionSetMembers.map(connection => [
`{{$.${connection.name}}}`,
<>
<Button
disabled={isLoading}
className="mr-2"
size="sm"
onClick={() => onEditConnection(connection.name)}
>
Edit Connection
</Button>
<Button
disabled={isLoading}
mode="destructive"
size="sm"
onClick={() => onRemoveConnection(connection.name)}
>
Remove
</Button>
</>,
]),
]}
/>
</div>
<div className="flex justify-end">
<Button type="submit" mode="primary">
Update Database Connection
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,134 @@
import { ConnectionSet } from '../../../../../../../metadata/types';
import {
useMetadata,
useMetadataMigration,
} from '../../../../../../MetadataAPI';
export const useDynamicDbRouting = ({ sourceName }: { sourceName: string }) => {
const { data, isLoading: isMetadaLoading } = useMetadata();
const { mutate, isLoading } = useMetadataMigration({});
const source = data?.metadata?.sources.find(
source => source.name === sourceName
);
const connectionTemplate =
source?.configuration?.connection_template?.template || null;
const connectionSet = source?.configuration?.connection_set || [];
const addConnection = async (
connection: ConnectionSet,
options?: Parameters<typeof mutate>[1]
): Promise<void> => {
mutate(
{
query: {
...(data?.resource_version && {
resource_version: data.resource_version,
}),
type: 'pg_update_source',
args: {
...source,
configuration: {
...source?.configuration,
connection_set: [...connectionSet, connection],
},
},
},
},
options
);
};
const removeConnection = async (
connectionName: string,
options?: Parameters<typeof mutate>[1]
): Promise<void> => {
mutate(
{
query: {
...(data?.resource_version && {
resource_version: data.resource_version,
}),
type: 'pg_update_source',
args: {
...source,
configuration: {
...source?.configuration,
connection_set:
connectionSet.length === 1
? null
: connectionSet.filter(c => c.name !== connectionName),
},
},
},
},
options
);
};
const updateConnection = async (
connectionName: string,
connection: ConnectionSet,
options?: Parameters<typeof mutate>[1]
): Promise<void> => {
mutate(
{
query: {
...(data?.resource_version && {
resource_version: data.resource_version,
}),
type: 'pg_update_source',
args: {
...source,
configuration: {
...source?.configuration,
connection_set: connectionSet.map(c =>
c.name === connectionName ? connection : c
),
},
},
},
},
options
);
};
const updateConnectionTemplate = async (
connectionTemplate?: string | null,
options?: Parameters<typeof mutate>[1]
): Promise<void> => {
mutate(
{
query: {
...(data?.resource_version && {
resource_version: data.resource_version,
}),
type: 'pg_update_source',
args: {
...source,
configuration: {
...source?.configuration,
connection_template: connectionTemplate
? { template: connectionTemplate }
: null,
},
},
},
},
options
);
};
return {
connectionTemplate,
connectionSet,
addConnection,
removeConnection,
updateConnection,
updateConnectionTemplate,
isLoading,
isMetadaLoading,
};
};

View File

@ -6,3 +6,4 @@ export { useTrackTable } from './TrackTables/hooks/useTrackTable';
export { useMetadataSource } from './TrackTables/hooks/useMetadataSource';
export * from './CustomFieldNames';
export * from '../../utils/getDataRoute';
export * from './mocks/metadata.mocks';

View File

@ -0,0 +1,61 @@
import produce from 'immer';
import { allowedMetadataTypes } from '../../MetadataAPI';
import { Metadata } from '../../hasura-metadata-types';
import { MetadataReducer } from '../../../mocks/actions';
export const dataInitialData: Partial<Metadata['metadata']> = {
sources: [
{
name: 'default',
kind: 'postgres',
tables: [],
configuration: {
connection_info: {
database_url: {
from_env: 'HASURA_GRAPHQL_DATABASE_URL',
},
isolation_level: 'read-committed',
pool_settings: {
connection_lifetime: 600,
idle_timeout: 180,
max_connections: 50,
retries: 1,
},
use_prepared_statements: true,
},
},
},
],
};
export const metadataHandlers: Partial<
Record<allowedMetadataTypes, MetadataReducer>
> = {
pg_update_source: (state, action) => {
const { name, configuration } = action.args as {
name: string;
configuration: Record<string, unknown>;
};
const existingSource = state.metadata.sources.find(s => s.name === name);
if (!existingSource) {
return {
status: 400,
error: {
path: '$.args.name',
error: `source with name "${name}" does not exist`,
code: 'not-exists',
},
};
}
return produce(state, draft => {
const source = draft.metadata.sources.find(s => s.name === name);
if (source?.configuration) {
source.configuration = {
...source?.configuration,
...configuration,
};
}
});
},
};

View File

@ -1,39 +1,54 @@
export type FromEnv = { from_env: string };
type ValidJson = Record<string, any>;
export interface PostgresConfiguration {
connection_info: {
database_url:
| string
| FromEnv
| {
username: string;
password?: string;
database: string;
host: string;
port: string;
};
pool_settings?: {
max_connections?: number;
total_max_connections?: number;
idle_timeout?: number;
retries?: number;
pool_timeout?: number;
connection_lifetime?: number;
};
use_prepared_statements?: boolean;
/**
* The transaction isolation level in which the queries made to the source will be run with (default: read-committed).
*/
isolation_level?: 'read-committed' | 'repeatable-read' | 'serializable';
ssl_configuration?: {
sslmode: string;
sslrootcert: FromEnv;
sslcert: FromEnv;
sslkey: FromEnv;
sslpassword: FromEnv;
};
interface PostgresConnectionInfo {
database_url:
| string
| FromEnv
| {
username: string;
password?: string;
database: string;
host: string;
port: string;
};
pool_settings?: {
max_connections?: number;
total_max_connections?: number;
idle_timeout?: number;
retries?: number;
pool_timeout?: number;
connection_lifetime?: number;
};
use_prepared_statements?: boolean;
/**
* The transaction isolation level in which the queries made to the source will be run with (default: read-committed).
*/
isolation_level?: 'read-committed' | 'repeatable-read' | 'serializable';
ssl_configuration?: {
sslmode: string;
sslrootcert: FromEnv;
sslcert: FromEnv;
sslkey: FromEnv;
sslpassword: FromEnv;
};
}
export interface PostgresConfiguration {
connection_info: PostgresConnectionInfo;
/**
* Kriti template to resolve connection info at runtime
*/
connection_template?: {
template: string;
};
/**
* List of connection sets to use in connection template
*/
connection_set?: {
name: string;
connection_info: PostgresConnectionInfo;
}[];
/**
* Optional list of read replica configuration (supported only in cloud/enterprise versions)
*/

View File

@ -1157,11 +1157,14 @@ type GraphQLCustomizationMetadata = {
};
// Used for Dynamic Connection Routing
type ConnectionSet = {
export type ConnectionSet = {
connection_info: SourceConnectionInfo;
name: string;
};
export type SourceConnectionTemplate = {
template: string | null;
};
export interface MetadataDataSource {
name: string;
kind:
@ -1177,6 +1180,7 @@ export interface MetadataDataSource {
extensions_schema?: string;
// pro-only feature
read_replicas?: SourceConnectionInfo[];
connection_template?: SourceConnectionTemplate;
connection_set?: ConnectionSet[];
service_account?: BigQueryServiceAccount;
global_select_limit?: number;

View File

@ -5,6 +5,7 @@ import { metadataHandlers as allowListMetadataHandlers } from '../features/Allow
import { metadataHandlers as adhocEventMetadataHandlers } from '../features/AdhocEvents';
import { metadataHandlers as queryCollectionMetadataHandlers } from '../features/QueryCollections';
import { metadataHandlers as openTelemetryMetadataHandlers } from '../features/OpenTelemetry';
import { metadataHandlers as dataMetadataHandlers } from '../features/Data';
import { TMigration } from '../features/MetadataAPI/hooks/useMetadataMigration';
@ -32,6 +33,7 @@ const metadataHandlers: Partial<Record<allowedMetadataTypes, MetadataReducer>> =
...queryCollectionMetadataHandlers,
...adhocEventMetadataHandlers,
...openTelemetryMetadataHandlers,
...dataMetadataHandlers,
};
export const metadataReducer: MetadataReducer = (state, action) => {

View File

@ -5,6 +5,7 @@ import type { Metadata } from '../features/hasura-metadata-types';
import { allowListInitialData } from '../features/AllowLists';
import { queryCollectionInitialData } from '../features/QueryCollections';
import { openTelemetryInitialData } from '../features/OpenTelemetry';
import { dataInitialData } from '../features/Data';
import { rest } from 'msw';
import { metadataReducer } from './actions';
@ -18,6 +19,7 @@ export const createDefaultInitialData = (): Metadata => ({
...allowListInitialData,
...queryCollectionInitialData,
...openTelemetryInitialData,
...dataInitialData,
},
});

View File

@ -8,7 +8,8 @@ export type BadgeColor =
| 'indigo'
| 'gray'
| 'blue'
| 'purple';
| 'purple'
| 'light-gray';
interface BadgeProps extends React.ComponentProps<'span'> {
/**
* The color of the basge
@ -24,6 +25,7 @@ const badgeClassnames: Record<BadgeColor, string> = {
indigo: 'bg-indigo-100 text-indigo-800',
blue: 'bg-blue-100 text-blue-800',
purple: 'bg-purple-100 text-purple-800',
'light-gray': 'bg-gray-100 text-gray-800',
};
export const Badge: React.FC<React.PropsWithChildren<BadgeProps>> = ({