mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
feat: add a one-click feature to create CRUD rest endpoints from a table
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9585 GitOrigin-RevId: 54757d51010a44211029d6f63714bfc08e152224
This commit is contained in:
parent
1a1e046b47
commit
fa398581f5
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { RestEndpointModal } from '../../Services/ApiExplorer/Rest/RestEndpointModal/RestEndpointModal';
|
||||
import { Button } from '../../../new-components/Button';
|
||||
import { FaLink } from 'react-icons/fa';
|
||||
import { Badge } from '../../../new-components/Badge';
|
||||
import { Analytics } from '../../../features/Analytics';
|
||||
|
||||
interface CreateRestEndpointProps {
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export const CreateRestEndpoint = (props: CreateRestEndpointProps) => {
|
||||
const { tableName } = props;
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||
|
||||
const toggleModal = () => {
|
||||
setIsModalOpen(!isModalOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Analytics name="data-tab-btn-create-rest-endpoints">
|
||||
<Button size="sm" onClick={toggleModal} icon={<FaLink />}>
|
||||
Create REST Endpoints{' '}
|
||||
<Badge className="pt-0 pb-0 pl-2 pr-2 ml-1" color="indigo">
|
||||
New
|
||||
</Badge>
|
||||
</Button>
|
||||
</Analytics>
|
||||
{isModalOpen && (
|
||||
<RestEndpointModal onClose={toggleModal} tableName={tableName} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -3,6 +3,7 @@ import { FaEdit } from 'react-icons/fa';
|
||||
import styles from '../Common.module.scss';
|
||||
import { PrimaryDBInfo } from './PrimaryDBInfo';
|
||||
import { TryOperation } from './TryOperation';
|
||||
import { CreateRestEndpoint } from './CreateRestEndpoints';
|
||||
|
||||
class Heading extends React.Component {
|
||||
state = {
|
||||
@ -51,7 +52,10 @@ class Heading extends React.Component {
|
||||
<>
|
||||
<div className={`${styles.editable_heading_text} pb-2`}>
|
||||
<h2>{currentValue}</h2>
|
||||
<TryOperation table={table} dispatch={dispatch} source={source} />
|
||||
<div className="text-base font-normal flex gap-2">
|
||||
<TryOperation table={table} dispatch={dispatch} source={source} />
|
||||
<CreateRestEndpoint tableName={table.table_name} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-5">
|
||||
<PrimaryDBInfo source={source} />
|
||||
@ -69,7 +73,10 @@ class Heading extends React.Component {
|
||||
<>
|
||||
<div className={styles.editable_heading_text}>
|
||||
<h2>{currentValue}</h2>
|
||||
<TryOperation table={table} dispatch={dispatch} source={source} />
|
||||
<div className="text-base font-normal flex gap-2">
|
||||
<TryOperation table={table} dispatch={dispatch} source={source} />
|
||||
<CreateRestEndpoint tableName={table.table_name} />
|
||||
</div>
|
||||
<div
|
||||
onClick={this.toggleEditting}
|
||||
className={styles.editable_heading_action}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { Link } from 'react-router';
|
||||
import { Link, RouteComponentProps } from 'react-router';
|
||||
import { FaEdit, FaTimes } from 'react-icons/fa';
|
||||
import { Analytics, REDACT_EVERYTHING } from '../../../../features/Analytics';
|
||||
import { LearnMoreLink } from '../../../../new-components/LearnMoreLink';
|
||||
@ -11,18 +11,28 @@ import { mapDispatchToPropsEmpty } from '../../../Common/utils/reactUtils';
|
||||
import AceEditor from '../../../Common/AceEditor/BaseEditor';
|
||||
import URLPreview from './URLPreview';
|
||||
import { allowedQueriesCollection } from '../../../../metadata/utils';
|
||||
import { dropRESTEndpoint } from '../../../../metadata/actions';
|
||||
import { dropRESTEndpoint, exportMetadata } from '../../../../metadata/actions';
|
||||
import _push from '../../Data/push';
|
||||
import Landing from './Landing';
|
||||
import { badgeSort } from './utils';
|
||||
import CollapsibleToggle from '../../../Common/CollapsibleToggle/CollapsibleToggle';
|
||||
import { ExportOpenApiButton } from './Form/ExportOpenAPI';
|
||||
|
||||
const ListComponent: React.FC<Props> = ({
|
||||
interface ListComponentProps extends Props {
|
||||
location: RouteComponentProps<unknown, unknown>['location'];
|
||||
}
|
||||
const ListComponent: React.FC<ListComponentProps> = ({
|
||||
location,
|
||||
restEndpoints,
|
||||
queryCollections,
|
||||
dispatch,
|
||||
}) => {
|
||||
// refetch metadata on mount
|
||||
useEffect(() => {
|
||||
dispatch(exportMetadata());
|
||||
}, []);
|
||||
|
||||
const highlighted = (location.query?.highlight as string)?.split(',') || [];
|
||||
const allowedQueries = queryCollections?.find(
|
||||
collection => collection.name === allowedQueriesCollection
|
||||
);
|
||||
@ -43,6 +53,10 @@ const ListComponent: React.FC<Props> = ({
|
||||
dispatch(_push(`/api/rest/edit/${link}`));
|
||||
};
|
||||
|
||||
const sortedEndpoints = [...restEndpoints].sort(endpoint =>
|
||||
highlighted.includes(endpoint.name) ? -1 : 1
|
||||
);
|
||||
|
||||
return (
|
||||
<Analytics name="RestList" {...REDACT_EVERYTHING}>
|
||||
<div className="pl-md pt-md pr-md">
|
||||
@ -80,7 +94,7 @@ const ListComponent: React.FC<Props> = ({
|
||||
</th>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{restEndpoints.map(endpoint => (
|
||||
{sortedEndpoints.map(endpoint => (
|
||||
<tr key={`rest_list_endpoint_${endpoint.name}`}>
|
||||
{/* Details */}
|
||||
<td className="px-sm py-xs max-w-xs align-top">
|
||||
@ -94,7 +108,14 @@ const ListComponent: React.FC<Props> = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<h4>{endpoint.name}</h4>
|
||||
<h4>
|
||||
{endpoint.name}{' '}
|
||||
{highlighted.includes(endpoint.name) && (
|
||||
<span className="relative bottom-2 text-green-700">
|
||||
●
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
</Link>
|
||||
</div>
|
||||
{endpoint.comment && <p>{endpoint.comment}</p>}
|
||||
|
@ -55,6 +55,7 @@ const Variables: React.FC<VariableComponentProps> = ({
|
||||
placeholder="Value..."
|
||||
onChange={updateVariableValue(v.name)}
|
||||
type="text"
|
||||
className="my-1 w-full"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -1,25 +1,25 @@
|
||||
import React from 'react';
|
||||
import z from 'zod';
|
||||
import { ComponentMeta } from '@storybook/react';
|
||||
import { RestEndpointModal, modalSchema } from './RestEndpointModal';
|
||||
import { SimpleForm } from '../../../../../new-components/Form';
|
||||
import { RestEndpointModal } from './RestEndpointModal';
|
||||
import { ReactQueryDecorator } from '../../../../../storybook/decorators/react-query';
|
||||
import { handlers } from '../../../../../mocks/metadata.mock';
|
||||
import { Meta } from '@storybook/react';
|
||||
import { rest } from 'msw';
|
||||
import introspectionSchema from '../../../../../features/RestEndpoints/hooks/mocks/introspectionWithoutCustomizations.json';
|
||||
|
||||
export default {
|
||||
title: 'Features/REST endpoints/Modal',
|
||||
component: RestEndpointModal,
|
||||
} as ComponentMeta<typeof RestEndpointModal>;
|
||||
|
||||
const modalInitialValues: z.infer<typeof modalSchema> = {
|
||||
tableName: 'Table',
|
||||
methods: ['CREATE', 'DELETE'],
|
||||
};
|
||||
decorators: [ReactQueryDecorator()],
|
||||
parameters: {
|
||||
msw: [
|
||||
...handlers({ delay: 500 }),
|
||||
rest.post(`http://localhost:8080/v1/graphql`, async (req, res, ctx) => {
|
||||
return res(ctx.json(introspectionSchema));
|
||||
}),
|
||||
],
|
||||
},
|
||||
} as Meta<typeof RestEndpointModal>;
|
||||
|
||||
export const Base = () => (
|
||||
<SimpleForm
|
||||
schema={modalSchema}
|
||||
options={{ defaultValues: modalInitialValues }}
|
||||
onSubmit={() => {}}
|
||||
>
|
||||
<RestEndpointModal />
|
||||
</SimpleForm>
|
||||
<RestEndpointModal tableName="user" onClose={() => {}} />
|
||||
);
|
||||
|
@ -1,48 +1,166 @@
|
||||
import React from 'react';
|
||||
import z from 'zod';
|
||||
import { Dialog } from '../../../../../new-components/Dialog';
|
||||
import {
|
||||
CheckboxesField,
|
||||
InputField,
|
||||
} from '../../../../../new-components/Form';
|
||||
import { Checkbox } from '../../../../../new-components/Form';
|
||||
import { useCreateRestEndpoints } from '../../../../../features/RestEndpoints/hooks/useCreateRestEndpoints';
|
||||
import { hasuraToast } from '../../../../../new-components/Toasts';
|
||||
import { EndpointType } from '../../../../../features/RestEndpoints/hooks/useRestEndpointDefinitions';
|
||||
import { IndicatorCard } from '../../../../../new-components/IndicatorCard';
|
||||
import { LearnMoreLink } from '../../../../../new-components/LearnMoreLink';
|
||||
import { CardedTable } from '../../../../../new-components/CardedTable';
|
||||
import { Badge, BadgeColor } from '../../../../../new-components/Badge';
|
||||
import { Link, browserHistory } from 'react-router';
|
||||
import { FaExclamation, FaExternalLinkAlt } from 'react-icons/fa';
|
||||
|
||||
export const modalSchema = z.object({
|
||||
tableName: z.string(),
|
||||
methods: z
|
||||
.enum(['READ', 'READ ALL', 'CREATE', 'UPDATE', 'DELETE'])
|
||||
.array()
|
||||
.nonempty({ message: 'Choose at least one method' }),
|
||||
});
|
||||
const ENDPOINTS: {
|
||||
value: EndpointType;
|
||||
label: string;
|
||||
color: BadgeColor;
|
||||
}[] = [
|
||||
{ value: 'READ', label: 'READ', color: 'indigo' },
|
||||
{ value: 'READ_ALL', label: 'READ ALL', color: 'indigo' },
|
||||
{ value: 'CREATE', label: 'CREATE', color: 'yellow' },
|
||||
{ value: 'UPDATE', label: 'UPDATE', color: 'yellow' },
|
||||
{ value: 'DELETE', label: 'DELETE', color: 'red' },
|
||||
];
|
||||
export interface RestEndpointModalProps {
|
||||
onClose: () => void;
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
export const RestEndpointModal = (props: RestEndpointModalProps) => {
|
||||
const { onClose, tableName } = props;
|
||||
const { createRestEndpoints, endpointDefinitions, isLoading } =
|
||||
useCreateRestEndpoints();
|
||||
|
||||
const tableEndpointDefinitions = endpointDefinitions?.[tableName] ?? {};
|
||||
|
||||
const [selectedMethods, setSelectedMethods] = React.useState<EndpointType[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
export const RestEndpointModal = () => {
|
||||
return (
|
||||
<Dialog
|
||||
hasBackdrop
|
||||
title="Auto-Create REST Endpoints"
|
||||
onClose={() => {}}
|
||||
onClose={onClose}
|
||||
description="One-click to create REST endpoint from selected table"
|
||||
footer={<Dialog.Footer callToAction="Create" onSubmit={() => {}} />}
|
||||
>
|
||||
<div className="p-md">
|
||||
<InputField
|
||||
name="tableName"
|
||||
label="Table Name"
|
||||
disabled
|
||||
className="w-2/3"
|
||||
/>
|
||||
footer={
|
||||
<Dialog.Footer
|
||||
onSubmitAnalyticsName="data-tab-rest-endpoints-modal-create"
|
||||
onCancelAnalyticsName="data-tab-rest-endpoints-modal-cancel"
|
||||
callToAction="Create"
|
||||
isLoading={isLoading}
|
||||
callToDeny="Cancel"
|
||||
onClose={onClose}
|
||||
disabled={selectedMethods.length === 0}
|
||||
onSubmit={() => {
|
||||
createRestEndpoints(tableName, selectedMethods, {
|
||||
onSuccess: () => {
|
||||
hasuraToast({
|
||||
type: 'success',
|
||||
title: 'Successfully generated rest endpoints',
|
||||
message: `Successfully generated rest endpoints for ${tableName}: ${selectedMethods.join(
|
||||
', '
|
||||
)}`,
|
||||
});
|
||||
onClose();
|
||||
const createdEndpoints = selectedMethods.map(
|
||||
method =>
|
||||
endpointDefinitions?.[tableName]?.[method]?.query?.name
|
||||
);
|
||||
|
||||
<CheckboxesField
|
||||
description="Each selected method will generate one endpoint"
|
||||
name="methods"
|
||||
label="Methods *"
|
||||
options={[
|
||||
{ value: 'READ', label: 'READ' },
|
||||
{ value: 'READ ALL', label: 'READ ALL' },
|
||||
{ value: 'CREATE', label: 'CREATE' },
|
||||
{ value: 'UPDATE', label: 'UPDATE' },
|
||||
{ value: 'DELETE', label: 'DELETE' },
|
||||
browserHistory.push(
|
||||
`/api/rest/list?highlight=${createdEndpoints.join(',')}`
|
||||
);
|
||||
},
|
||||
onError: error => {
|
||||
hasuraToast({
|
||||
type: 'error',
|
||||
title: 'Failed to generate endpoints',
|
||||
message: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<CardedTable
|
||||
columns={[
|
||||
<Checkbox
|
||||
checked={selectedMethods.length === ENDPOINTS.length}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setSelectedMethods(ENDPOINTS.map(endpoint => endpoint.value));
|
||||
} else {
|
||||
setSelectedMethods([]);
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
'OPERATION',
|
||||
'METHOD',
|
||||
'PATH',
|
||||
]}
|
||||
orientation="horizontal"
|
||||
data={ENDPOINTS.map(method => {
|
||||
const endpointDefinition =
|
||||
tableEndpointDefinitions[method.value as EndpointType];
|
||||
|
||||
return [
|
||||
<Checkbox
|
||||
checked={selectedMethods.includes(method.value as EndpointType)}
|
||||
disabled={endpointDefinition?.exists}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setSelectedMethods([
|
||||
...selectedMethods,
|
||||
method.value as EndpointType,
|
||||
]);
|
||||
} else {
|
||||
setSelectedMethods(
|
||||
selectedMethods.filter(
|
||||
selectedMethod => selectedMethod !== method.value
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
<div>
|
||||
{endpointDefinition?.exists ? (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/api/rest/details/${endpointDefinition.restEndpoint?.name}`,
|
||||
state: {
|
||||
...endpointDefinition.restEndpoint,
|
||||
currentQuery: endpointDefinition.query.query,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{method.label}{' '}
|
||||
<FaExternalLinkAlt className="relative ml-1 -top-0.5" />
|
||||
</Link>
|
||||
) : (
|
||||
method.label
|
||||
)}
|
||||
</div>,
|
||||
<Badge color={method.color}>
|
||||
{endpointDefinition?.restEndpoint?.methods?.join(', ')}
|
||||
</Badge>,
|
||||
<div>/{endpointDefinition?.restEndpoint?.url ?? 'N/A'}</div>,
|
||||
];
|
||||
})}
|
||||
/>
|
||||
<IndicatorCard
|
||||
showIcon
|
||||
children={
|
||||
<div>
|
||||
Creating REST Endpoints will add metadata entries to your Hasura
|
||||
project
|
||||
<LearnMoreLink href="https://hasura.io/docs/latest/restified/overview" />
|
||||
</div>
|
||||
}
|
||||
status="info"
|
||||
customIcon={FaExclamation}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
@ -294,7 +294,11 @@ const getValueWithType = (variableData: VariableState) => {
|
||||
}
|
||||
|
||||
// NOTE: bool_exp are of JSON type, so pass it as JSON object (issue: https://github.com/hasura/graphql-engine/issues/9671)
|
||||
if (variableData.type.endsWith('_exp') && isJsonString(variableData.value)) {
|
||||
if (
|
||||
(variableData.type.endsWith('_exp') ||
|
||||
variableData.type.endsWith('_input')) &&
|
||||
isJsonString(variableData.value)
|
||||
) {
|
||||
return JSON.parse(variableData.value);
|
||||
}
|
||||
|
||||
|
@ -5,4 +5,7 @@ export {
|
||||
export { useEditOperationInQueryCollection } from './useEditOperationInQueryCollection';
|
||||
export { useMoveOperationsToQueryCollection } from './useMoveOperationsToQueryCollection';
|
||||
export { useOperationsFromQueryCollection } from './useOperationsFromQueryCollection';
|
||||
export { useRemoveOperationsFromQueryCollection } from './useRemoveOperationsFromQueryCollection';
|
||||
export {
|
||||
useRemoveOperationsFromQueryCollection,
|
||||
removeOperationsFromQueryCollectionMetadataArgs,
|
||||
} from './useRemoveOperationsFromQueryCollection';
|
||||
|
@ -23,6 +23,7 @@ export const createAddOperationToQueryCollectionMetadataArgs = (
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
export const useAddOperationsToQueryCollection = () => {
|
||||
const { mutate, ...rest } = useMetadataMigration();
|
||||
|
||||
|
@ -3,6 +3,19 @@ import { useCallback } from 'react';
|
||||
import { useMetadata, useMetadataMigration } from '../../../MetadataAPI';
|
||||
import { QueryCollection } from '../../../../metadata/types';
|
||||
|
||||
export const removeOperationsFromQueryCollectionMetadataArgs = (
|
||||
queryCollection: string,
|
||||
queries: string[]
|
||||
) => [
|
||||
...queries.map(query => ({
|
||||
type: 'drop_query_from_collection',
|
||||
args: {
|
||||
collection_name: queryCollection,
|
||||
query_name: query,
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
export const useRemoveOperationsFromQueryCollection = () => {
|
||||
const { mutate, ...rest } = useMetadataMigration();
|
||||
|
||||
@ -27,13 +40,10 @@ export const useRemoveOperationsFromQueryCollection = () => {
|
||||
...(metadata?.resource_version && {
|
||||
resource_version: metadata.resource_version,
|
||||
}),
|
||||
args: queries.map(query => ({
|
||||
type: 'drop_query_from_collection',
|
||||
args: {
|
||||
collection_name: queryCollection,
|
||||
query_name: query.name,
|
||||
},
|
||||
})),
|
||||
args: removeOperationsFromQueryCollectionMetadataArgs(
|
||||
queryCollection,
|
||||
queries.map(query => query.name)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -52,5 +52,5 @@ export const useCreateRestEndpoints = () => {
|
||||
[endpointDefinitions, mutate, metadata]
|
||||
);
|
||||
|
||||
return { createRestEndpoints, isReady, ...rest };
|
||||
return { createRestEndpoints, endpointDefinitions, isReady, ...rest };
|
||||
};
|
||||
|
@ -0,0 +1,42 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMetadata, useMetadataMigration } from '../../MetadataAPI';
|
||||
import { removeOperationsFromQueryCollectionMetadataArgs } from '../../QueryCollections/hooks';
|
||||
|
||||
export const useDeleteRestEndpoints = () => {
|
||||
const { mutate, ...rest } = useMetadataMigration();
|
||||
|
||||
const { data: metadata } = useMetadata();
|
||||
|
||||
const deleteRestEndpoints = useCallback(
|
||||
(endpoints: string[], options?: Parameters<typeof mutate>[1]) => {
|
||||
return mutate(
|
||||
{
|
||||
query: {
|
||||
type: 'bulk',
|
||||
...(metadata?.resource_version && {
|
||||
resource_version: metadata.resource_version,
|
||||
}),
|
||||
args: [
|
||||
...endpoints.map(endpoint => ({
|
||||
type: 'drop_rest_endpoint',
|
||||
args: {
|
||||
name: endpoint,
|
||||
},
|
||||
})),
|
||||
...removeOperationsFromQueryCollectionMetadataArgs(
|
||||
'allowed-queries',
|
||||
endpoints
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
...options,
|
||||
}
|
||||
);
|
||||
},
|
||||
[mutate, metadata]
|
||||
);
|
||||
|
||||
return { deleteRestEndpoints, ...rest };
|
||||
};
|
@ -3,6 +3,7 @@ import { useIntrospectionSchema } from '../../../components/Services/Actions/Com
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Query, RestEndpoint } from '../../hasura-metadata-types';
|
||||
import {
|
||||
Operation,
|
||||
generateDeleteEndpoint,
|
||||
generateInsertEndpoint,
|
||||
generateUpdateEndpoint,
|
||||
@ -10,8 +11,9 @@ import {
|
||||
generateViewEndpoint,
|
||||
} from './utils';
|
||||
import { formatSdl } from 'format-graphql';
|
||||
import { useMetadata } from '../../MetadataAPI';
|
||||
|
||||
export type EndpointType = 'VIEW' | 'VIEW_ALL' | 'CREATE' | 'UPDATE' | 'DELETE';
|
||||
export type EndpointType = 'READ' | 'READ_ALL' | 'CREATE' | 'UPDATE' | 'DELETE';
|
||||
|
||||
export type EndpointDefinition = {
|
||||
restEndpoint: RestEndpoint;
|
||||
@ -19,7 +21,9 @@ export type EndpointDefinition = {
|
||||
};
|
||||
|
||||
type EndpointDefinitions = {
|
||||
[key: string]: Partial<Record<EndpointType, EndpointDefinition>>;
|
||||
[key: string]: Partial<
|
||||
Record<EndpointType, EndpointDefinition & { exists: boolean }>
|
||||
>;
|
||||
};
|
||||
|
||||
export type Generator = {
|
||||
@ -27,7 +31,7 @@ export type Generator = {
|
||||
generator: (
|
||||
root: string,
|
||||
table: string,
|
||||
operation: any,
|
||||
operation: Operation,
|
||||
microfiber: any
|
||||
) => EndpointDefinition;
|
||||
};
|
||||
@ -85,11 +89,11 @@ export const getOperations = (microfiber: any) => {
|
||||
};
|
||||
|
||||
const generators: Record<EndpointType, Generator> = {
|
||||
VIEW: {
|
||||
READ: {
|
||||
regExp: /fetch data from the table: "(.+)" using primary key columns$/,
|
||||
generator: generateViewEndpoint,
|
||||
},
|
||||
VIEW_ALL: {
|
||||
READ_ALL: {
|
||||
regExp: /fetch data from the table: "(.+)"$/,
|
||||
generator: generateViewAllEndpoint,
|
||||
},
|
||||
@ -115,9 +119,13 @@ export const useRestEndpointDefinitions = () => {
|
||||
error,
|
||||
} = useIntrospectionSchema();
|
||||
|
||||
const { data: metadata } = useMetadata();
|
||||
|
||||
const [data, setData] = useState<EndpointDefinitions>();
|
||||
|
||||
useEffect(() => {
|
||||
const existingRestEndpoints = metadata?.metadata?.rest_endpoints || [];
|
||||
|
||||
if (introspectionSchema) {
|
||||
const response: EndpointDefinitions = {};
|
||||
const microfiber = new Microfiber(introspectionSchema);
|
||||
@ -131,7 +139,7 @@ export const useRestEndpointDefinitions = () => {
|
||||
|
||||
for (const operation of operations.operations) {
|
||||
for (const endpointType in generators) {
|
||||
const match = operation.description.match(
|
||||
const match = operation.description?.match(
|
||||
generators[endpointType as EndpointType].regExp
|
||||
);
|
||||
const table = match?.[1];
|
||||
@ -147,14 +155,21 @@ export const useRestEndpointDefinitions = () => {
|
||||
|
||||
response[table] = {
|
||||
...(response[table] || {}),
|
||||
[endpointType]: definition,
|
||||
[endpointType]: {
|
||||
...definition,
|
||||
exists: existingRestEndpoints.some(
|
||||
endpoint =>
|
||||
endpoint.definition.query.query_name ===
|
||||
definition.query.name
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
setData(response);
|
||||
}
|
||||
}, [introspectionSchema]);
|
||||
}, [introspectionSchema, metadata]);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
@ -281,8 +281,8 @@ describe('generateUpdateEndpoint', () => {
|
||||
expect(query).toEqual({
|
||||
name: 'update_user_by_pk',
|
||||
query:
|
||||
formatSdl(`mutation update_user_by_pk($id: user_pk_columns_input!, $object: user_set_input!) {
|
||||
update_user_by_pk(pk_columns: $id, _set: $object) {
|
||||
formatSdl(`mutation update_user_by_pk($id: Int!, $object: user_set_input!) {
|
||||
update_user_by_pk(pk_columns: {id: $id}, _set: $object) {
|
||||
address
|
||||
bool
|
||||
count
|
||||
@ -322,9 +322,9 @@ describe('generateUpdateEndpoint', () => {
|
||||
expect(query).toEqual({
|
||||
name: 'a_update_user_by_pk_b',
|
||||
query:
|
||||
formatSdl(`mutation a_update_user_by_pk_b($id: c_user_pk_columns_input_d!, $object: c_user_set_input_d!) {
|
||||
formatSdl(`mutation a_update_user_by_pk_b($id: Int!, $object: c_user_set_input_d!) {
|
||||
root_ {
|
||||
a_update_user_by_pk_b(pk_columns: $id, _set: $object) {
|
||||
a_update_user_by_pk_b(pk_columns: {id: $id}, _set: $object) {
|
||||
address
|
||||
bool
|
||||
count
|
||||
|
@ -12,14 +12,55 @@ const wrapRoot = (root: string, operation: string) => {
|
||||
return root ? `${root} { ${operation} }` : operation;
|
||||
};
|
||||
|
||||
const extractFields = (operation: any, microfiber: any) => {
|
||||
const type = microfiber.getType(recursiveType(operation.type));
|
||||
export type Operation = {
|
||||
name: string;
|
||||
type: GraphQLType;
|
||||
args: Field[];
|
||||
};
|
||||
|
||||
type Field = {
|
||||
type: GraphQLType;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type MicrofiberType = {
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an operation , it extract all the scalar fields of the type of the operation.
|
||||
* This exlcudes, for example, all the relationship fields
|
||||
*/
|
||||
const extractFields = (operation: Operation, microfiber: any) => {
|
||||
const type: MicrofiberType = microfiber.getType(
|
||||
recursiveType(operation.type)
|
||||
);
|
||||
const fields = type?.fields
|
||||
?.filter((field: any) => recursiveType(field.type)?.kind === 'SCALAR')
|
||||
?.filter(field => recursiveType(field.type)?.kind === 'SCALAR')
|
||||
?.map((f: { name: string }) => f.name);
|
||||
return { fields };
|
||||
};
|
||||
|
||||
/**
|
||||
* This function builds parts of the query for a given operation.
|
||||
* It process the args and produces as output the fields of the operation,
|
||||
* the fields of the query and the URL path
|
||||
*/
|
||||
const buildArgs = (fields: Field[]) => {
|
||||
const args = fields?.map(arg => ({
|
||||
name: arg.name,
|
||||
type: recursiveType(arg.type)?.name,
|
||||
}));
|
||||
|
||||
const operationArgs = args?.map(arg => `${arg.name}: $${arg.name}`);
|
||||
|
||||
const queryArgs = args?.map(arg => `$${arg.name}: ${arg.type}!`);
|
||||
|
||||
const path = fields?.map(arg => `:${arg.name}`).join('/');
|
||||
|
||||
return { queryArgs, operationArgs, path };
|
||||
};
|
||||
|
||||
export const recursiveType = (type?: GraphQLType): GraphQLType | undefined => {
|
||||
if (!type) {
|
||||
return undefined;
|
||||
@ -38,12 +79,10 @@ export const generateViewEndpoint: Generator['generator'] = (
|
||||
) => {
|
||||
const { fields } = extractFields(operation, microfiber);
|
||||
|
||||
const idType = recursiveType(
|
||||
operation.args?.find((arg: any) => arg.name === 'id')?.type
|
||||
)?.name;
|
||||
const { queryArgs, operationArgs, path } = buildArgs(operation.args);
|
||||
|
||||
const grapqhlOperation = `
|
||||
${operation.name}(id: $id) {
|
||||
${operation.name}(${operationArgs}) {
|
||||
${fields?.join('\n')}
|
||||
}
|
||||
`;
|
||||
@ -51,14 +90,14 @@ export const generateViewEndpoint: Generator['generator'] = (
|
||||
const query: Query = {
|
||||
name: operation.name,
|
||||
query: formatSdl(`
|
||||
query ${operation.name}($id: ${idType}!) {
|
||||
query ${operation.name}(${queryArgs}) {
|
||||
${wrapRoot(root, grapqhlOperation)}
|
||||
}`),
|
||||
};
|
||||
|
||||
const restEndpoint: RestEndpoint = {
|
||||
name: operation.name,
|
||||
url: `${table}/:id`,
|
||||
url: `${table}/${path}`,
|
||||
methods: ['GET'],
|
||||
definition: {
|
||||
query: {
|
||||
@ -117,12 +156,11 @@ export const generateDeleteEndpoint: Generator['generator'] = (
|
||||
microfiber
|
||||
) => {
|
||||
const { fields } = extractFields(operation, microfiber);
|
||||
const idType = recursiveType(
|
||||
operation.args?.find((arg: any) => arg.name === 'id')?.type
|
||||
)?.name;
|
||||
|
||||
const { queryArgs, operationArgs, path } = buildArgs(operation.args);
|
||||
|
||||
const grapqhlOperation = `
|
||||
${operation.name}(id: $id) {
|
||||
${operation.name}(${operationArgs}) {
|
||||
${fields?.join('\n')}
|
||||
}
|
||||
`;
|
||||
@ -130,14 +168,14 @@ export const generateDeleteEndpoint: Generator['generator'] = (
|
||||
const query: Query = {
|
||||
name: operation.name,
|
||||
query: formatSdl(`
|
||||
mutation ${operation.name}($id: ${idType}!) {
|
||||
mutation ${operation.name}(${queryArgs}) {
|
||||
${wrapRoot(root, grapqhlOperation)}
|
||||
}`),
|
||||
};
|
||||
|
||||
const restEndpoint: RestEndpoint = {
|
||||
name: operation.name,
|
||||
url: `${table}/:id`,
|
||||
url: `${table}/${path}`,
|
||||
methods: ['DELETE'],
|
||||
definition: {
|
||||
query: {
|
||||
@ -159,7 +197,7 @@ export const generateInsertEndpoint: Generator['generator'] = (
|
||||
) => {
|
||||
const { fields } = extractFields(operation, microfiber);
|
||||
const inputType = recursiveType(
|
||||
operation.args?.find((arg: any) => arg.name === 'object')?.type
|
||||
operation.args?.find(arg => arg.name === 'object')?.type
|
||||
)?.name;
|
||||
|
||||
const grapqhlOperation = `
|
||||
@ -199,16 +237,26 @@ export const generateUpdateEndpoint: Generator['generator'] = (
|
||||
microfiber
|
||||
) => {
|
||||
const { fields } = extractFields(operation, microfiber);
|
||||
const idType = recursiveType(
|
||||
operation.args?.find((arg: any) => arg.name === 'pk_columns')?.type
|
||||
|
||||
const pkTypeName = recursiveType(
|
||||
operation.args?.find(arg => arg.name === 'pk_columns')?.type
|
||||
)?.name;
|
||||
|
||||
const pkType = microfiber.getType({
|
||||
kind: 'INPUT_OBJECT',
|
||||
name: pkTypeName,
|
||||
});
|
||||
|
||||
const { queryArgs, operationArgs, path } = buildArgs(pkType?.inputFields);
|
||||
|
||||
const inputType = recursiveType(
|
||||
operation.args?.find((arg: any) => arg.name === '_set')?.type
|
||||
operation.args?.find(arg => arg.name === '_set')?.type
|
||||
)?.name;
|
||||
|
||||
const grapqhlOperation = `
|
||||
${operation.name}(pk_columns: $id, _set: $object) {
|
||||
${operation.name}(pk_columns: {
|
||||
${operationArgs}
|
||||
}, _set: $object) {
|
||||
${fields?.join('\n')}
|
||||
}
|
||||
`;
|
||||
@ -216,14 +264,14 @@ export const generateUpdateEndpoint: Generator['generator'] = (
|
||||
const query: Query = {
|
||||
name: operation.name,
|
||||
query: formatSdl(`
|
||||
mutation ${operation.name}($id: ${idType}!, $object: ${inputType}!) {
|
||||
mutation ${operation.name}(${queryArgs}, $object: ${inputType}!) {
|
||||
${wrapRoot(root, grapqhlOperation)}
|
||||
}`),
|
||||
};
|
||||
|
||||
const restEndpoint: RestEndpoint = {
|
||||
name: operation.name,
|
||||
url: `${table}/:id`,
|
||||
url: `${table}/${path}`,
|
||||
methods: ['POST'],
|
||||
definition: {
|
||||
query: {
|
||||
|
@ -62,7 +62,7 @@ export const IndicatorCard = ({
|
||||
{showIcon ? (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center justify-center h-xl w-xl rounded-full mr-md',
|
||||
'flex items-center justify-center h-xl w-xl rounded-full mr-md flex-shrink-0',
|
||||
iconColorsPerStatus[status]
|
||||
)}
|
||||
>
|
||||
|
Loading…
Reference in New Issue
Block a user