mirror of
https://github.com/hasura/graphql-engine.git
synced 2025-01-05 14:27:59 +03:00
feat(console): add import single endpoint from openAPI
PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6970 GitOrigin-RevId: 14bbca9efbda6fa71f6b00ab5d45062d71abd794
This commit is contained in:
parent
32a316aef7
commit
16561d2c9a
@ -80,6 +80,8 @@ module.exports = {
|
||||
});
|
||||
config.resolve.alias['@'] = path.resolve(__dirname, '../src');
|
||||
|
||||
config.node = { fs: 'empty' };
|
||||
|
||||
if (isConfigDebugMode) {
|
||||
console.log('------WEBPACK--------');
|
||||
console.log(util.inspect(newConfig, { showHidden: false, depth: null }));
|
||||
|
2547
console/package-lock.json
generated
2547
console/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -118,6 +118,7 @@
|
||||
"immer": "9.0.12",
|
||||
"inflection": "1.12.0",
|
||||
"isomorphic-fetch": "2.2.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"jwt-decode": "^3.0.0",
|
||||
"less": "3.11.1",
|
||||
@ -128,7 +129,9 @@
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.pickby": "^4.6.0",
|
||||
"lodash.uniqueid": "^4.0.1",
|
||||
"microfiber": "^1.3.1",
|
||||
"moment": "^2.26.0",
|
||||
"openapi-to-graphql": "^2.6.3",
|
||||
"piping": "0.3.2",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.13.1",
|
||||
@ -213,6 +216,7 @@
|
||||
"@types/isomorphic-fetch": "0.0.35",
|
||||
"@types/jest": "^26.0.22",
|
||||
"@types/jquery": "3.3.33",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/jwt-decode": "2.2.1",
|
||||
"@types/lodash": "^4.14.159",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
|
@ -44,6 +44,7 @@ import {
|
||||
ResponseTransformStateBody,
|
||||
} from '@/components/Common/ConfigureTransformation/stateDefaults';
|
||||
import ConfigureTransformation from '@/components/Common/ConfigureTransformation/ConfigureTransformation';
|
||||
import { GeneratedAction, OasGeneratorModal } from '@/features/Actions';
|
||||
import ActionEditor from '../Common/components/ActionEditor';
|
||||
import { createAction } from '../ServerIO';
|
||||
import { getActionDefinitionFromSdl } from '../../../../shared/utils/sdlUtils';
|
||||
@ -92,6 +93,8 @@ const AddAction: React.FC<AddActionProps> = ({
|
||||
getActionResponseTransformDefaultState()
|
||||
);
|
||||
|
||||
const createActionRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnlyMode)
|
||||
dispatch(
|
||||
@ -239,6 +242,8 @@ const AddAction: React.FC<AddActionProps> = ({
|
||||
const responseBodyOnChange = (responseBody: ResponseTransformStateBody) => {
|
||||
responseTransformDispatch(setResponseBody(responseBody));
|
||||
};
|
||||
const [isActionGeneratorOpen, setIsActionGeneratorOpen] =
|
||||
React.useState(false);
|
||||
|
||||
// we send separate requests for the `url` preview and `body` preview, as in case of error,
|
||||
// we will not be able to resolve if the error is with url or body transform, with the current state of `test_webhook_transform` api
|
||||
@ -345,6 +350,71 @@ const AddAction: React.FC<AddActionProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const onImportGeneratedAction = (generatedAction: GeneratedAction): void => {
|
||||
const {
|
||||
types,
|
||||
action,
|
||||
description,
|
||||
method,
|
||||
baseUrl,
|
||||
path,
|
||||
requestTransforms,
|
||||
responseTransforms,
|
||||
headers: actionHeaders,
|
||||
sampleInput,
|
||||
queryParams,
|
||||
} = generatedAction;
|
||||
typeDefinitionOnChange(types, null, null, null);
|
||||
actionDefinitionOnChange(action, null, null, null);
|
||||
commentOnChange({
|
||||
target: { value: description },
|
||||
} as React.ChangeEvent<HTMLInputElement>);
|
||||
handlerOnChange(baseUrl);
|
||||
if (requestTransforms) {
|
||||
requestPayloadTransformOnChange(true);
|
||||
requestBodyOnChange({
|
||||
action: 'transform',
|
||||
template: requestTransforms,
|
||||
});
|
||||
} else {
|
||||
requestPayloadTransformOnChange(false);
|
||||
}
|
||||
toggleForwardClientHeaders();
|
||||
if (responseTransforms) {
|
||||
responsePayloadTransformOnChange(true);
|
||||
responseBodyOnChange({
|
||||
action: 'transform',
|
||||
template: responseTransforms,
|
||||
});
|
||||
} else {
|
||||
responsePayloadTransformOnChange(false);
|
||||
}
|
||||
|
||||
transformDispatch(setRequestSampleInput(sampleInput));
|
||||
requestMethodOnChange(method);
|
||||
requestUrlTransformOnChange(true);
|
||||
requestUrlOnChange(path.replace(/\{([^}]+)\}/g, '{{$body.input.$1}}'));
|
||||
requestQueryParamsOnChange(
|
||||
queryParams.map(name => ({
|
||||
name,
|
||||
value: `{{$body.input.${name}}}`,
|
||||
}))
|
||||
);
|
||||
setHeaders(
|
||||
actionHeaders.map(name => ({
|
||||
name,
|
||||
value: `{{$body.input.${name}}}`,
|
||||
type: 'static',
|
||||
}))
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
if (createActionRef.current) {
|
||||
createActionRef.current.scrollIntoView();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Analytics name="AddAction" {...REDACT_EVERYTHING}>
|
||||
<div className="w-full overflow-y-auto bg-gray-50">
|
||||
@ -352,6 +422,13 @@ const AddAction: React.FC<AddActionProps> = ({
|
||||
<Helmet title="Add Action - Actions | Hasura" />
|
||||
<h2 className="font-bold text-xl mb-5">Add a new action</h2>
|
||||
|
||||
{isActionGeneratorOpen && (
|
||||
<OasGeneratorModal
|
||||
onImport={onImportGeneratedAction}
|
||||
onClose={() => setIsActionGeneratorOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ActionEditor
|
||||
handler={handler}
|
||||
execution={execution}
|
||||
@ -371,6 +448,7 @@ const AddAction: React.FC<AddActionProps> = ({
|
||||
toggleForwardClientHeaders={toggleForwardClientHeaders}
|
||||
actionDefinitionOnChange={actionDefinitionOnChange}
|
||||
typeDefinitionOnChange={typeDefinitionOnChange}
|
||||
onOpenActionGenerator={() => setIsActionGeneratorOpen(true)}
|
||||
/>
|
||||
|
||||
<ConfigureTransformation
|
||||
@ -393,7 +471,7 @@ const AddAction: React.FC<AddActionProps> = ({
|
||||
responseBodyOnChange={responseBodyOnChange}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div ref={createActionRef}>
|
||||
<Analytics
|
||||
name="actions-tab-create-action-button"
|
||||
passHtmlAttributesToChildren
|
||||
|
@ -23,7 +23,8 @@ const getArgObjFromDefinition = (
|
||||
type = getTrimmedType(type);
|
||||
const name = arg?.name;
|
||||
if (type === 'String' || type === 'ID') return { [name]: `${name}` };
|
||||
if (type === 'Int' || type === 'Float') return { [name]: 10 };
|
||||
if (type === 'Int' || type === 'Float' || type === 'BigInt')
|
||||
return { [name]: 10 };
|
||||
if (type === 'Boolean') return { [name]: false };
|
||||
|
||||
const userDefType = typesdef?.types.find(
|
||||
@ -38,6 +39,13 @@ const getArgObjFromDefinition = (
|
||||
[name]: obj,
|
||||
};
|
||||
}
|
||||
|
||||
if (userDefType?.kind === 'enum') {
|
||||
return {
|
||||
[name]: userDefType.values?.[0]?.value ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
|
@ -1,8 +1,15 @@
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
import React from 'react';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { IconTooltip } from '@/new-components/Tooltip';
|
||||
import { Button } from '@/new-components/Button';
|
||||
import { Analytics, REDACT_EVERYTHING } from '@/features/Analytics';
|
||||
import {
|
||||
availableFeatureFlagIds,
|
||||
FeatureFlagToast,
|
||||
useIsFeatureFlagEnabled,
|
||||
} from '@/features/FeatureFlags';
|
||||
import { isProConsole } from '@/utils';
|
||||
import { FaMagic } from 'react-icons/fa';
|
||||
import HandlerEditor from './HandlerEditor';
|
||||
import ExecutionEditor from './ExecutionEditor';
|
||||
@ -45,6 +52,7 @@ type ActionEditorProps = {
|
||||
timer: Nullable<NodeJS.Timeout>,
|
||||
ast: Nullable<Record<string, any>>
|
||||
) => void;
|
||||
onOpenActionGenerator?: () => void;
|
||||
};
|
||||
|
||||
const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
@ -66,6 +74,7 @@ const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
toggleForwardClientHeaders,
|
||||
actionDefinitionOnChange,
|
||||
typeDefinitionOnChange,
|
||||
onOpenActionGenerator,
|
||||
}) => {
|
||||
const {
|
||||
sdl: typesDefinitionSdl,
|
||||
@ -81,6 +90,10 @@ const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
|
||||
const [isTypesGeneratorOpen, setIsTypesGeneratorOpen] = React.useState(false);
|
||||
|
||||
const { enabled: isImportFromOASEnabled } = useIsFeatureFlagEnabled(
|
||||
availableFeatureFlagIds.importActionFromOpenApiId
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Analytics name="ActionEditor" {...REDACT_EVERYTHING}>
|
||||
@ -125,6 +138,19 @@ const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
You can use the custom types already defined by you or define new
|
||||
types in the new types definition editor below.
|
||||
</p>
|
||||
{onOpenActionGenerator &&
|
||||
isProConsole(window.__env) &&
|
||||
(isImportFromOASEnabled ? (
|
||||
<div className="mb-xs">
|
||||
<Button icon={<FaMagic />} onClick={onOpenActionGenerator}>
|
||||
Import from OpenAPI
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<FeatureFlagToast
|
||||
flagId={availableFeatureFlagIds.importActionFromOpenApiId}
|
||||
/>
|
||||
))}
|
||||
<GraphQLEditor
|
||||
value={actionDefinitionSdl}
|
||||
error={actionDefinitionError}
|
||||
|
@ -0,0 +1,316 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import z from 'zod';
|
||||
import {
|
||||
CodeEditorField,
|
||||
FieldWrapper,
|
||||
InputField,
|
||||
Radio,
|
||||
} from '@/new-components/Form';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useDebouncedEffect } from '@/hooks/useDebounceEffect';
|
||||
import { Oas3 } from 'openapi-to-graphql';
|
||||
import YAML from 'js-yaml';
|
||||
|
||||
import { Badge, BadgeColor } from '@/new-components/Badge';
|
||||
import { trackCustomEvent } from '@/features/Analytics';
|
||||
import { useIsUnmounted } from '@/components/Services/Data';
|
||||
import { generateAction, getOperations } from './utils';
|
||||
import { GeneratedAction, Operation } from './types';
|
||||
|
||||
export const formSchema = z.object({
|
||||
oas: z.string(),
|
||||
operation: z.string(),
|
||||
url: z.string(),
|
||||
search: z.string(),
|
||||
});
|
||||
|
||||
const editorOptions = {
|
||||
minLines: 16,
|
||||
maxLines: 16,
|
||||
showLineNumbers: true,
|
||||
useSoftTabs: true,
|
||||
showPrintMargin: false,
|
||||
};
|
||||
|
||||
const badgeColors: Record<string, BadgeColor> = {
|
||||
GET: 'green',
|
||||
POST: 'blue',
|
||||
PUT: 'yellow',
|
||||
DELETE: 'red',
|
||||
PATCH: 'purple',
|
||||
};
|
||||
export const OasGeneratorForm = (props: {
|
||||
setValues: (values?: GeneratedAction) => void;
|
||||
}) => {
|
||||
const { setValues } = props;
|
||||
|
||||
const [operations, setOperations] = React.useState<Operation[]>([]);
|
||||
const [parsedOas, setParsedOas] = React.useState<Oas3 | null>(null);
|
||||
const [isOasTooBig, setIsOasTooBig] = React.useState(false);
|
||||
const [selectedMethods, setSelectedMethods] = React.useState<string[]>([]);
|
||||
|
||||
const isUnMounted = useIsUnmounted();
|
||||
|
||||
const { watch, setValue, setError, clearErrors } = useFormContext();
|
||||
const oas = watch('oas');
|
||||
const operation = watch('operation');
|
||||
const search = watch('search');
|
||||
const url = watch('url');
|
||||
|
||||
const filteredOperations = React.useMemo(() => {
|
||||
return operations.filter(op => {
|
||||
const searchMatch =
|
||||
!search ||
|
||||
op.operationId.toLowerCase().includes(search.toLowerCase()) ||
|
||||
op.path.toLowerCase().includes(search.toLowerCase()) ||
|
||||
op.method.toLowerCase().includes(search.toLowerCase());
|
||||
const methodMatch =
|
||||
selectedMethods.length === 0 ||
|
||||
selectedMethods.includes(op.method.toUpperCase());
|
||||
return searchMatch && methodMatch;
|
||||
});
|
||||
}, [operations, search, selectedMethods]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (parsedOas && operation) {
|
||||
try {
|
||||
const generatedAction = await generateAction(parsedOas, operation);
|
||||
if (!isUnMounted()) {
|
||||
setValues({ ...generatedAction, baseUrl: url });
|
||||
}
|
||||
} catch (e) {
|
||||
setError('operation', {
|
||||
message: `Failed to generate action: ${(e as Error).message}`,
|
||||
});
|
||||
trackCustomEvent(
|
||||
{
|
||||
location: 'Import OAS Modal',
|
||||
action: 'generate',
|
||||
object: 'errors',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
errors: (e as Error).message,
|
||||
},
|
||||
}
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [setValues, operation, operations, url, parsedOas, setError, isUnMounted]);
|
||||
|
||||
useDebouncedEffect(
|
||||
async () => {
|
||||
let localParsedOas: Oas3 | undefined;
|
||||
clearErrors();
|
||||
setOperations([]);
|
||||
if (oas && oas?.trim() !== '') {
|
||||
try {
|
||||
localParsedOas = JSON.parse(oas) as Oas3;
|
||||
} catch (e) {
|
||||
try {
|
||||
localParsedOas = YAML.load(oas) as Oas3;
|
||||
} catch (e2) {
|
||||
setError('oas', {
|
||||
message: 'Invalid spec',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (localParsedOas) {
|
||||
if (!url && localParsedOas.servers?.[0].url) {
|
||||
setValue('url', localParsedOas.servers?.[0].url);
|
||||
}
|
||||
const ops = await getOperations(localParsedOas);
|
||||
setOperations(ops);
|
||||
|
||||
// send number of operations and file length
|
||||
|
||||
const size = oas?.length || 0;
|
||||
const numberOfOperations = ops?.length || 0;
|
||||
|
||||
trackCustomEvent(
|
||||
{
|
||||
location: 'Import OAS Modal',
|
||||
action: 'change',
|
||||
object: 'specification',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
size,
|
||||
numberOfOperations: numberOfOperations.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setError('oas', {
|
||||
message: `Invalid spec: ${(e as Error).message}`,
|
||||
});
|
||||
}
|
||||
|
||||
setParsedOas(localParsedOas ?? null);
|
||||
},
|
||||
400,
|
||||
[oas, clearErrors, setError, setValue, url]
|
||||
);
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = loadEvent => {
|
||||
if (loadEvent.target) {
|
||||
// set isOasTooBig to true if the oas is larger than 512kb
|
||||
setIsOasTooBig(
|
||||
(loadEvent?.target?.result as string)?.length > 512 * 1024
|
||||
);
|
||||
setValue('oas', loadEvent.target.result);
|
||||
}
|
||||
};
|
||||
// set error
|
||||
reader.onerror = () => {
|
||||
setError('oas', {
|
||||
message: 'Invalid spec',
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsText(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<FieldWrapper
|
||||
label="Upload a YAML or JSON file"
|
||||
id="file"
|
||||
tooltip="Upload a YAML or JSON file containing your OpenAPI specification to fill up the form below."
|
||||
>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
data-testid="file"
|
||||
type="file"
|
||||
id="file"
|
||||
aria-invalid="false"
|
||||
aria-label="upload open api specification"
|
||||
onChange={handleFileUpload}
|
||||
accept=".json,.yaml,.yml"
|
||||
/>
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
|
||||
{isOasTooBig ? (
|
||||
<div className="mb-8">
|
||||
<Badge color="yellow">File is too big to show</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-96 mb-4" data-testid="oas-editor">
|
||||
<CodeEditorField
|
||||
noErrorPlaceholder
|
||||
name="oas"
|
||||
label="Or paste an Open API Specification"
|
||||
tooltip="Enter a sample request in JSON or YAML format to generate the input type"
|
||||
editorOptions={editorOptions}
|
||||
editorProps={{
|
||||
className: 'rounded`-r-none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{operations?.length > 0 ? (
|
||||
<div className="flex flex-col">
|
||||
<FieldWrapper
|
||||
label="Configure server URL"
|
||||
id="url"
|
||||
className="-mt-4 -mb-6"
|
||||
>
|
||||
<InputField
|
||||
size="medium"
|
||||
name="url"
|
||||
type="text"
|
||||
placeholder="http://example.com"
|
||||
/>
|
||||
</FieldWrapper>
|
||||
<FieldWrapper label="Search" id="search" className="-mb-6">
|
||||
<InputField
|
||||
size="medium"
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search endpoints..."
|
||||
icon={<FaSearch />}
|
||||
/>
|
||||
{/* add badges to filter based on http method */}
|
||||
<div className="flex flex-row space-x-2 -mt-2 mb-4">
|
||||
{Object.keys(badgeColors).map(method => (
|
||||
<Badge
|
||||
onClick={() => {
|
||||
if (selectedMethods.includes(method)) {
|
||||
setSelectedMethods(
|
||||
selectedMethods.filter(m => m !== method)
|
||||
);
|
||||
} else {
|
||||
setSelectedMethods([...selectedMethods, method]);
|
||||
}
|
||||
}}
|
||||
data-testid={`badge-${method}`}
|
||||
key={method}
|
||||
color={
|
||||
selectedMethods.includes(method)
|
||||
? badgeColors[method]
|
||||
: 'gray'
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{method}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
|
||||
<Radio
|
||||
name="operation"
|
||||
label="Choose an endpoint:"
|
||||
orientation="vertical"
|
||||
tooltip="Choose an endpoint to generate the input type"
|
||||
options={[
|
||||
...filteredOperations.slice(0, 50).map(op => ({
|
||||
label: (
|
||||
<div className="text-bold my-1">
|
||||
<Badge
|
||||
color={badgeColors[op.method.toUpperCase()]}
|
||||
className="text-xs inline-flex w-16 justify-center mr-2"
|
||||
>
|
||||
{op.method.toUpperCase()}
|
||||
</Badge>
|
||||
{op.path}
|
||||
</div>
|
||||
),
|
||||
value: op.operationId,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{filteredOperations.length > 50 && (
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
{filteredOperations.length - 50} more endpoints... Use search to
|
||||
filter them
|
||||
</div>
|
||||
)}
|
||||
{filteredOperations.length === 0 && (
|
||||
<div className="text-sm text-gray-500 mb-2">
|
||||
No endpoints found. Try changing the search or the selected
|
||||
method
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Story, Meta } from '@storybook/react';
|
||||
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
|
||||
import { handlers } from '@/mocks/metadata.mock';
|
||||
import { within, userEvent } from '@storybook/testing-library';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { OasGeneratorModal, OasGeneratorModalProps } from './OASGeneratorModal';
|
||||
import petstore from './petstore.json';
|
||||
|
||||
export default {
|
||||
title: 'Features/Actions/OASGeneratorModal',
|
||||
component: OasGeneratorModal,
|
||||
decorators: [ReactQueryDecorator()],
|
||||
parameters: {
|
||||
msw: handlers({ delay: 500 }),
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
export const Default: Story<OasGeneratorModalProps> = args => {
|
||||
return <OasGeneratorModal {...args} />;
|
||||
};
|
||||
|
||||
Default.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByTestId('file');
|
||||
userEvent.upload(
|
||||
input,
|
||||
new File([JSON.stringify(petstore)], 'test.json', {
|
||||
type: 'application/json',
|
||||
})
|
||||
);
|
||||
|
||||
// wait for searchbox to appear
|
||||
const searchBox = await canvas.findByTestId('search');
|
||||
// count number of operations
|
||||
expect(canvas.getAllByTestId(/^operation.*/)).toHaveLength(4);
|
||||
// search operations with 'get'
|
||||
userEvent.type(searchBox, 'GET');
|
||||
// count filtered number of operations
|
||||
expect(canvas.getAllByTestId(/^operation.*/)).toHaveLength(2);
|
||||
// clear search
|
||||
userEvent.clear(searchBox);
|
||||
// search not existing operation
|
||||
userEvent.type(searchBox, 'not-existing');
|
||||
// look for 'No endpoints found' message
|
||||
expect(canvas.getByText(/No endpoints found/)).toBeInTheDocument();
|
||||
// clear search
|
||||
userEvent.clear(searchBox);
|
||||
// click on 'POST' badge
|
||||
userEvent.click(canvas.getByTestId('badge-POST'));
|
||||
// count filtered number of operations
|
||||
expect(canvas.getAllByTestId(/^operation.*/)).toHaveLength(1);
|
||||
// Generate action button should be disabled
|
||||
expect(canvas.getByText('Generate Action').parentElement).toBeDisabled();
|
||||
// click on the first operation
|
||||
userEvent.click(canvas.getByTestId(/^operation.*/));
|
||||
// wait for generate action button to be enabled
|
||||
await waitFor(() => {
|
||||
return (
|
||||
canvas
|
||||
.getByText('Generate Action')
|
||||
.parentElement?.getAttribute('disabled') === ''
|
||||
);
|
||||
});
|
||||
expect(canvas.getByText('Generate Action').parentElement).not.toBeDisabled();
|
||||
};
|
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Dialog } from '@/new-components/Dialog';
|
||||
import { Form } from '@/new-components/Form';
|
||||
|
||||
import { formSchema, OasGeneratorForm } from './OASGeneratorForm';
|
||||
import { GeneratedAction } from './types';
|
||||
|
||||
export interface OasGeneratorModalProps {
|
||||
onImport: (output: GeneratedAction) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const OasGeneratorModal = (props: OasGeneratorModalProps) => {
|
||||
const { onClose, onImport } = props;
|
||||
const [values, setValues] = React.useState<GeneratedAction>();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
size="xl"
|
||||
footer={
|
||||
<Dialog.Footer
|
||||
onSubmit={
|
||||
values
|
||||
? () => {
|
||||
onImport(values);
|
||||
onClose();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={onClose}
|
||||
callToDeny="Cancel"
|
||||
callToAction="Generate Action"
|
||||
disabled={!values}
|
||||
/>
|
||||
}
|
||||
title="Import OpenAPI endpoint"
|
||||
hasBackdrop
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="px-sm">
|
||||
<p className="text-muted mb-6">
|
||||
Generate your action from a Open API spec.
|
||||
</p>
|
||||
<Form className="pl-0 pr-0" schema={formSchema} onSubmit={() => {}}>
|
||||
{() => <OasGeneratorForm setValues={setValues} />}
|
||||
</Form>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
1
console/src/features/Actions/components/OASGeneratorModal/external.types.d.ts
vendored
Normal file
1
console/src/features/Actions/components/OASGeneratorModal/external.types.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module 'microfiber';
|
@ -0,0 +1,2 @@
|
||||
export { OasGeneratorModal } from './OASGeneratorModal';
|
||||
export { GeneratedAction } from './types';
|
@ -0,0 +1,235 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"version": "1.0.0",
|
||||
"title": "Swagger Petstore",
|
||||
"description": "A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification",
|
||||
"termsOfService": "http://swagger.io/terms/",
|
||||
"contact": {
|
||||
"name": "Swagger API Team",
|
||||
"email": "apiteam@swagger.io",
|
||||
"url": "http://swagger.io"
|
||||
},
|
||||
"license": {
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://petstore.swagger.io/api"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/pets": {
|
||||
"get": {
|
||||
"description": "Returns all pets from the system that the user has access to\nNam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia.\n\nSed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien.\n",
|
||||
"operationId": "findPets",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tags",
|
||||
"in": "query",
|
||||
"description": "tags to filter by",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "maximum number of results to return",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "pet response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Pet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "unexpected error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new pet in the store. Duplicates are allowed",
|
||||
"operationId": "addPet",
|
||||
"requestBody": {
|
||||
"description": "Pet to add to the store",
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NewPet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "pet response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Pet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "unexpected error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/pets/{id}": {
|
||||
"get": {
|
||||
"description": "Returns a user based on a single ID, if the user does not have access to the pet",
|
||||
"operationId": "find pet by id",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "ID of pet to fetch",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "pet response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Pet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "unexpected error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "deletes a single pet based on the ID supplied",
|
||||
"operationId": "deletePet",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "ID of pet to delete",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "pet deleted"
|
||||
},
|
||||
"default": {
|
||||
"description": "unexpected error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Pet": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/NewPet"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"NewPet": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { RequestTransformMethod } from '@/metadata/types';
|
||||
import { createGraphQLSchema } from 'openapi-to-graphql';
|
||||
import z from 'zod';
|
||||
import { formSchema } from './OASGeneratorForm';
|
||||
|
||||
export type SchemaType = z.infer<typeof formSchema>;
|
||||
export type GeneratedAction = {
|
||||
operationId: string;
|
||||
actionType: 'query' | 'mutation';
|
||||
types: string;
|
||||
action: string;
|
||||
description: string;
|
||||
method: RequestTransformMethod;
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
requestTransforms: string;
|
||||
responseTransforms: string;
|
||||
sampleInput: string;
|
||||
headers: string[];
|
||||
queryParams: string[];
|
||||
};
|
||||
|
||||
export type Result = Awaited<ReturnType<typeof createGraphQLSchema>>;
|
||||
export type Operation = Result['data']['operations'][0];
|
||||
|
||||
export type DataDefinition = Operation['responseDefinition'];
|
||||
export type SubDefinition = Operation['responseDefinition']['subDefinitions'];
|
@ -0,0 +1,66 @@
|
||||
import { Oas3 } from 'openapi-to-graphql';
|
||||
import petStore from './petstore.json';
|
||||
import { getOperations, generateAction } from './utils';
|
||||
|
||||
describe('getOperations', () => {
|
||||
it('should return an array of operations', async () => {
|
||||
const operations = await getOperations(petStore as unknown as Oas3);
|
||||
expect(operations).toHaveLength(4);
|
||||
expect(operations[0].path).toBe('/pets');
|
||||
expect(operations[0].method).toBe('get');
|
||||
expect(operations[1].path).toBe('/pets');
|
||||
expect(operations[1].method).toBe('post');
|
||||
expect(operations[2].path).toBe('/pets/{id}');
|
||||
expect(operations[2].method).toBe('get');
|
||||
expect(operations[3].path).toBe('/pets/{id}');
|
||||
expect(operations[3].method).toBe('delete');
|
||||
});
|
||||
|
||||
it('throws an error if the OAS is invalid', async () => {
|
||||
await expect(getOperations({} as unknown as Oas3)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAction', () => {
|
||||
it('should return an action', async () => {
|
||||
const action = await generateAction(
|
||||
petStore as unknown as Oas3,
|
||||
'findPets'
|
||||
);
|
||||
expect(action).toEqual({
|
||||
operationId: 'findPets',
|
||||
actionType: 'query',
|
||||
action:
|
||||
'type Query {\n findPets(limit: Int, tags: [String]): [Pet]\n}\n',
|
||||
types:
|
||||
'scalar BigInt\n\ntype Pet {\n id: BigInt!\n name: String!\n tag: String\n}\n',
|
||||
description:
|
||||
'Returns all pets from the system that the user has access to\n' +
|
||||
'Nam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia.\n' +
|
||||
'\n' +
|
||||
'Sed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien.\n',
|
||||
method: 'GET',
|
||||
baseUrl: 'http://petstore.swagger.io/api',
|
||||
path: '/pets',
|
||||
requestTransforms: '',
|
||||
responseTransforms: '',
|
||||
sampleInput:
|
||||
'{\n' +
|
||||
' "action": {\n' +
|
||||
' "name": "findPets"\n' +
|
||||
' },\n' +
|
||||
' "input": {\n' +
|
||||
' "limit": 10\n' +
|
||||
' }\n' +
|
||||
'}',
|
||||
headers: [],
|
||||
queryParams: ['tags', 'limit'],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if the OAS is invalid', async () => {
|
||||
await expect(
|
||||
generateAction({} as unknown as Oas3, 'findPets')
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
@ -0,0 +1,447 @@
|
||||
import { RequestTransformMethod } from '@/metadata/types';
|
||||
import {
|
||||
buildClientSchema,
|
||||
getIntrospectionQuery,
|
||||
graphqlSync,
|
||||
printSchema,
|
||||
} from 'graphql';
|
||||
import {
|
||||
GraphQLOperationType,
|
||||
Oas2,
|
||||
Oas3,
|
||||
createGraphQLSchema,
|
||||
} from 'openapi-to-graphql';
|
||||
import {
|
||||
ReferenceObject,
|
||||
SchemaObject,
|
||||
} from 'openapi-to-graphql/dist/types/oas3';
|
||||
import { Microfiber } from 'microfiber';
|
||||
import { formatSdl } from 'format-graphql';
|
||||
import { getActionRequestSampleInput } from '../../../../components/Services/Actions/Add/utils';
|
||||
import {
|
||||
DataDefinition,
|
||||
GeneratedAction,
|
||||
Operation,
|
||||
Result,
|
||||
SubDefinition,
|
||||
} from './types';
|
||||
|
||||
const parseRequestMethod = (method: string): RequestTransformMethod => {
|
||||
switch (method.toLowerCase()) {
|
||||
case 'get':
|
||||
return 'GET';
|
||||
case 'post':
|
||||
return 'POST';
|
||||
case 'put':
|
||||
return 'PUT';
|
||||
case 'delete':
|
||||
return 'DELETE';
|
||||
case 'patch':
|
||||
return 'PATCH';
|
||||
default:
|
||||
return 'GET';
|
||||
}
|
||||
};
|
||||
|
||||
const lastOfArray = (arr: string[]): string => {
|
||||
return arr[arr.length - 1];
|
||||
};
|
||||
|
||||
export const formatQuery = (query?: string): string => {
|
||||
try {
|
||||
return !query || query.trim() === '' ? '' : formatSdl(query);
|
||||
} catch (e) {
|
||||
return query ?? '';
|
||||
}
|
||||
};
|
||||
|
||||
const lowerCaseFirstLetter = (str: string): string => {
|
||||
return str ? str.charAt(0).toLowerCase() + str.slice(1) : '';
|
||||
};
|
||||
|
||||
interface Transform {
|
||||
transform: Record<string, unknown>;
|
||||
needTransform: boolean;
|
||||
}
|
||||
|
||||
const createTransform = (
|
||||
definition: SubDefinition,
|
||||
prefix: string,
|
||||
inverse: boolean
|
||||
): Transform => {
|
||||
try {
|
||||
if (Array.isArray(definition)) {
|
||||
// union type not supported at the moment
|
||||
return { transform: {}, needTransform: false };
|
||||
}
|
||||
|
||||
if (
|
||||
definition &&
|
||||
'preferredName' in definition &&
|
||||
typeof definition.preferredName === 'string'
|
||||
) {
|
||||
const newPrefix = prefix.split(/\./)[prefix.split(/\./).length - 1];
|
||||
const { transform, needTransform } = createTransform(
|
||||
definition.subDefinitions,
|
||||
newPrefix,
|
||||
inverse
|
||||
);
|
||||
return {
|
||||
transform: {
|
||||
[`ARRAYSTART(${prefix})`]: true,
|
||||
...transform,
|
||||
ARRAYEND: true,
|
||||
},
|
||||
needTransform,
|
||||
};
|
||||
}
|
||||
|
||||
let needTransform = false;
|
||||
const transform = Object.entries(definition).reduce((acc, curr) => {
|
||||
const value = curr[1] as DataDefinition;
|
||||
const keyFrom = inverse
|
||||
? lowerCaseFirstLetter(value.preferredName)
|
||||
: curr[0];
|
||||
const keyTo = inverse
|
||||
? curr[0]
|
||||
: lowerCaseFirstLetter(value.preferredName);
|
||||
if (keyFrom !== keyTo) {
|
||||
needTransform = true;
|
||||
}
|
||||
if (
|
||||
value.schema.type === 'object' ||
|
||||
(value.schema.type === 'array' &&
|
||||
'subDefinitions' in value.subDefinitions &&
|
||||
value.subDefinitions.subDefinitions)
|
||||
) {
|
||||
const {
|
||||
transform: childrenTransform,
|
||||
needTransform: childrenNeedTransform,
|
||||
} = createTransform(
|
||||
value.subDefinitions,
|
||||
`${prefix}?.${keyTo}`,
|
||||
inverse
|
||||
);
|
||||
needTransform = needTransform || childrenNeedTransform;
|
||||
return {
|
||||
...acc,
|
||||
[keyFrom]: childrenTransform,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[keyFrom]: `{{${prefix}?.${keyTo}}}`,
|
||||
};
|
||||
}, {});
|
||||
return {
|
||||
transform,
|
||||
needTransform,
|
||||
};
|
||||
} catch (e) {
|
||||
return { transform: {}, needTransform: false };
|
||||
}
|
||||
};
|
||||
|
||||
const createSampleInput = (
|
||||
definition: SubDefinition
|
||||
): Record<string, unknown> | Record<string, unknown>[] => {
|
||||
try {
|
||||
if (Array.isArray(definition)) {
|
||||
// union type not supported at the moment
|
||||
return {};
|
||||
}
|
||||
|
||||
if (
|
||||
'preferredName' in definition &&
|
||||
typeof definition.preferredName === 'string'
|
||||
) {
|
||||
return [
|
||||
{
|
||||
...createSampleInput(definition.subDefinitions),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return Object.entries(definition).reduce((acc, curr) => {
|
||||
const value = curr[1] as DataDefinition;
|
||||
const keyFrom = lowerCaseFirstLetter(value.preferredName);
|
||||
const keyTo = curr[0];
|
||||
|
||||
if (
|
||||
value.schema.type === 'object' ||
|
||||
(value.schema.type === 'array' &&
|
||||
'subDefinitions' in value.subDefinitions &&
|
||||
value.subDefinitions.subDefinitions)
|
||||
) {
|
||||
return {
|
||||
...acc,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
[keyFrom]: createSampleInput(value.subDefinitions),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...acc,
|
||||
[keyFrom]: `${keyTo}`,
|
||||
};
|
||||
}, {});
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* transform the generated JSON into a valid kriti template
|
||||
*/
|
||||
const postProcessTransform = (output: Transform): string | null => {
|
||||
const { transform, needTransform } = output;
|
||||
if (!needTransform) {
|
||||
return null;
|
||||
}
|
||||
let string = JSON.stringify(transform, null, 2)
|
||||
// removing quotes from values
|
||||
.replace(/\"(.*)\": \"(.*)\"/g, '"$1": $2')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\n/g, '\n\t')
|
||||
// processing arrays
|
||||
.replace(
|
||||
/\"(.*?)\": \{\n\s*\"ARRAYSTART\((.*)\)\": true,/g,
|
||||
'"$1": {{if inverse(empty($2))}} {{ range _, $1 := $2}} {'
|
||||
)
|
||||
.replace(
|
||||
/,(\n\s*?)\"ARRAYEND\": true\n\s*?}/g,
|
||||
'$1} {{end}} {{else}} null {{end}}'
|
||||
);
|
||||
|
||||
// processing root array
|
||||
if (string.match(/\{\n\s*\"ARRAYSTART\((.*)\)\": true,/g)) {
|
||||
string = string
|
||||
.replace(
|
||||
/\{\n\s*\"ARRAYSTART\((.*)\)\": true,/g,
|
||||
'{{if inverse(empty($_body))}} {{ range _, item := $_body}} {'
|
||||
)
|
||||
.replace(/\$body/g, 'item')
|
||||
.replace(/\$_body/g, '$body');
|
||||
}
|
||||
return string;
|
||||
};
|
||||
|
||||
export const createRequestTransform = (operation: Operation): string => {
|
||||
let inputName = '';
|
||||
const inputObjectType = operation.payloadDefinition?.graphQLInputObjectType;
|
||||
if (inputObjectType && 'name' in inputObjectType) {
|
||||
inputName = lowerCaseFirstLetter(inputObjectType.name);
|
||||
}
|
||||
if (operation.payloadDefinition?.subDefinitions) {
|
||||
const defaultRequestTransform = ['POST', 'PUT', 'PATCH'].includes(
|
||||
operation.method.toUpperCase()
|
||||
)
|
||||
? `{{$body.input.${inputName}}}`
|
||||
: '';
|
||||
return (
|
||||
postProcessTransform(
|
||||
createTransform(
|
||||
operation.payloadDefinition.subDefinitions,
|
||||
`$body.input.${inputName}`,
|
||||
false
|
||||
)
|
||||
) ?? defaultRequestTransform
|
||||
);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
export const createResponseTransform = (operation: Operation): string => {
|
||||
if (operation.responseDefinition?.targetGraphQLType === 'string') {
|
||||
return '{{$body}}';
|
||||
}
|
||||
if (operation.responseDefinition?.subDefinitions) {
|
||||
return (
|
||||
postProcessTransform(
|
||||
createTransform(
|
||||
operation.responseDefinition.subDefinitions,
|
||||
'$body',
|
||||
true
|
||||
)
|
||||
) ?? ''
|
||||
);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const translateAction = (
|
||||
graphqlSchema: Result,
|
||||
operation: Operation
|
||||
): GeneratedAction => {
|
||||
const { schema } = graphqlSchema;
|
||||
|
||||
const introspectionQuery = graphqlSync({
|
||||
schema,
|
||||
source: getIntrospectionQuery(),
|
||||
});
|
||||
|
||||
if (introspectionQuery.errors) {
|
||||
const joinedMessage = introspectionQuery.errors
|
||||
.map(e => e.message)
|
||||
.join('\n');
|
||||
throw new Error(joinedMessage);
|
||||
}
|
||||
|
||||
const microfiber = new Microfiber(introspectionQuery, {
|
||||
cleanupSchemaImmediately: false,
|
||||
});
|
||||
const queryNamesToRemove = (
|
||||
microfiber.getQueryType().fields as { name: string; description: string }[]
|
||||
)
|
||||
?.filter(
|
||||
field =>
|
||||
!field.description
|
||||
.trim()
|
||||
.endsWith(lastOfArray(operation.description.trim().split('\n\n')))
|
||||
)
|
||||
?.map(field => field.name);
|
||||
|
||||
const mutationNamesToRemove = (
|
||||
microfiber.getMutationType().fields as {
|
||||
name: string;
|
||||
description: string;
|
||||
}[]
|
||||
)
|
||||
?.filter(
|
||||
field =>
|
||||
!field.description
|
||||
.trim()
|
||||
.endsWith(lastOfArray(operation.description.trim().split('\n\n')))
|
||||
)
|
||||
?.map(field => field.name);
|
||||
|
||||
queryNamesToRemove?.forEach(query => {
|
||||
microfiber.removeQuery({ name: query, cleanup: false });
|
||||
});
|
||||
|
||||
mutationNamesToRemove?.forEach(mutation => {
|
||||
microfiber.removeMutation({ name: mutation, cleanup: false });
|
||||
});
|
||||
|
||||
microfiber.cleanSchema();
|
||||
|
||||
const newSchema = buildClientSchema(microfiber.getResponse().data);
|
||||
|
||||
const sdl = printSchema(newSchema);
|
||||
|
||||
const sdlWithoutComments = sdl.replace(/"""[^]*?"""/g, '');
|
||||
// extratct type query from sdl;
|
||||
const typeQuery = sdlWithoutComments.match(/type Query {[^]*?}/g)?.[0];
|
||||
const typeMutation = sdlWithoutComments.match(/type Mutation {[^]*?}/g)?.[0];
|
||||
const action = formatQuery(typeQuery || typeMutation) ?? '';
|
||||
|
||||
// remove type query and type mutation from sdl
|
||||
const sdlWithoutTypeQuery = formatQuery(
|
||||
sdlWithoutComments
|
||||
.replace(/"""[^]*?"""/g, '')
|
||||
.replace(/type Query {[^]*?}/g, '')
|
||||
.replace(/type Mutation {[^]*?}/g, '')
|
||||
.replace('type Query', '')
|
||||
.replace('type Mutation', '')
|
||||
);
|
||||
|
||||
let sampleInput = JSON.parse(
|
||||
getActionRequestSampleInput(action, sdlWithoutTypeQuery)
|
||||
);
|
||||
|
||||
if (operation.payloadDefinition?.subDefinitions) {
|
||||
sampleInput = {
|
||||
...sampleInput,
|
||||
input: Object.keys(sampleInput.input).reduce((acc, curr) => {
|
||||
return {
|
||||
...acc,
|
||||
[curr]:
|
||||
curr.toLowerCase() ===
|
||||
operation.payloadDefinition?.graphQLTypeName.toLowerCase()
|
||||
? createSampleInput(
|
||||
operation.payloadDefinition?.subDefinitions ?? {}
|
||||
)
|
||||
: sampleInput.input[curr],
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
}
|
||||
|
||||
const headers =
|
||||
operation.parameters
|
||||
?.filter(param => param.in === 'header')
|
||||
?.map(param => param.name) || [];
|
||||
|
||||
const queryParams =
|
||||
operation.parameters
|
||||
?.filter(param => param.in === 'query')
|
||||
?.map(param => param.name) || [];
|
||||
return {
|
||||
operationId: operation.operationId,
|
||||
actionType:
|
||||
operation.operationType === GraphQLOperationType.Query
|
||||
? 'query'
|
||||
: 'mutation',
|
||||
action,
|
||||
types: sdlWithoutTypeQuery ?? '',
|
||||
description: operation?.operation?.description ?? '',
|
||||
method: parseRequestMethod(operation?.method),
|
||||
baseUrl: graphqlSchema.data?.oass?.[0]?.servers?.[0]?.url ?? '',
|
||||
path: operation.path,
|
||||
requestTransforms: createRequestTransform(operation) ?? '',
|
||||
responseTransforms: createResponseTransform(operation) ?? '',
|
||||
sampleInput: JSON.stringify(sampleInput, null, 2),
|
||||
headers,
|
||||
queryParams,
|
||||
};
|
||||
};
|
||||
|
||||
const applyWorkarounds = (properties: (SchemaObject | ReferenceObject)[]) => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const property of Object.values(properties ?? {})) {
|
||||
if (!('$ref' in property)) {
|
||||
// fix boolean enum issue
|
||||
if (property.type === 'boolean') {
|
||||
console.log(property);
|
||||
delete property.enum;
|
||||
}
|
||||
// fix empty type issue
|
||||
if (
|
||||
property.type === 'object' &&
|
||||
JSON.stringify(property.properties) === '{}'
|
||||
) {
|
||||
property.type = 'string';
|
||||
delete property.properties;
|
||||
}
|
||||
if (property.properties) {
|
||||
applyWorkarounds(Object.values(property.properties));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const parseOas = async (oas: Oas2 | Oas3): Promise<Result> => {
|
||||
const oasCopy = JSON.parse(JSON.stringify(oas)) as Oas3;
|
||||
if (oasCopy.components?.schemas) {
|
||||
applyWorkarounds(Object.values(oasCopy.components?.schemas));
|
||||
}
|
||||
|
||||
return createGraphQLSchema(oasCopy, {
|
||||
fillEmptyResponses: true,
|
||||
operationIdFieldNames: true,
|
||||
simpleEnumValues: true,
|
||||
viewer: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const generateAction = async (
|
||||
oas: Oas2 | Oas3,
|
||||
operationId: string
|
||||
): Promise<GeneratedAction> => {
|
||||
const graphqlSchema = await parseOas(oas);
|
||||
const operation = graphqlSchema.data.operations[operationId];
|
||||
return translateAction(graphqlSchema, operation);
|
||||
};
|
||||
|
||||
export const getOperations = async (oas: Oas2 | Oas3): Promise<Operation[]> => {
|
||||
const graphqlSchema = await parseOas(oas);
|
||||
return Object.values(graphqlSchema.data.operations);
|
||||
};
|
1
console/src/features/Actions/index.ts
Normal file
1
console/src/features/Actions/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './components/OASGeneratorModal/';
|
@ -1,13 +1,25 @@
|
||||
import { isProConsole } from '@/utils';
|
||||
import { FeatureFlagDefinition } from './types';
|
||||
|
||||
const relationshipTabTablesId = '0bea35ff-d3e9-45e9-af1b-59923bf82fa9';
|
||||
const gdcId = '88436c32-2798-11ed-a261-0242ac120002';
|
||||
const allowListId = '3a042e0c-e0d4-46b6-8c87-01f2ed9f63b0';
|
||||
const importActionFromOpenApiId = '12e5aaf4-c794-4b8f-b762-5fda0bff946a';
|
||||
|
||||
export const availableFeatureFlagIds = {
|
||||
relationshipTabTablesId,
|
||||
gdcId,
|
||||
allowListId,
|
||||
importActionFromOpenApiId,
|
||||
};
|
||||
|
||||
const importActionFromOpenApi: FeatureFlagDefinition = {
|
||||
id: importActionFromOpenApiId,
|
||||
title: 'Import Action from OpenAPI',
|
||||
description:
|
||||
'Try out the very experimental feature to generate one action from an OpenAPI endpoint',
|
||||
section: 'data',
|
||||
status: 'experimental',
|
||||
defaultValue: false,
|
||||
discussionUrl: '',
|
||||
};
|
||||
|
||||
export const availableFeatureFlags: FeatureFlagDefinition[] = [
|
||||
@ -31,4 +43,6 @@ export const availableFeatureFlags: FeatureFlagDefinition[] = [
|
||||
defaultValue: false,
|
||||
discussionUrl: '',
|
||||
},
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
...(isProConsole(window.__env) ? [importActionFromOpenApi] : []),
|
||||
];
|
||||
|
@ -1,7 +1,14 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type BadgeColor = 'green' | 'red' | 'yellow' | 'indigo' | 'gray';
|
||||
export type BadgeColor =
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'yellow'
|
||||
| 'indigo'
|
||||
| 'gray'
|
||||
| 'blue'
|
||||
| 'purple';
|
||||
interface BadgeProps extends React.ComponentProps<'span'> {
|
||||
/**
|
||||
* The color of the basge
|
||||
@ -15,6 +22,8 @@ const badgeClassnames: Record<BadgeColor, string> = {
|
||||
yellow: 'bg-yellow-100 text-yellow-800',
|
||||
gray: 'bg-gray-300 text-gray-800',
|
||||
indigo: 'bg-indigo-100 text-indigo-800',
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
export const Badge: React.FC<React.PropsWithChildren<BadgeProps>> = ({
|
||||
|
@ -16,6 +16,7 @@ export type FooterProps = {
|
||||
className?: string;
|
||||
onSubmitAnalyticsName?: string;
|
||||
onCancelAnalyticsName?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const Footer: React.VFC<FooterProps> = ({
|
||||
@ -28,6 +29,7 @@ const Footer: React.VFC<FooterProps> = ({
|
||||
className,
|
||||
onSubmitAnalyticsName,
|
||||
onCancelAnalyticsName,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const callToActionProps = onSubmit ? { onClick: onSubmit } : {};
|
||||
|
||||
@ -54,6 +56,7 @@ const Footer: React.VFC<FooterProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
disabled={disabled}
|
||||
{...callToActionProps}
|
||||
type="submit"
|
||||
mode="primary"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { ReactText } from 'react';
|
||||
import React, { ReactNode, ReactText } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import get from 'lodash.get';
|
||||
@ -6,7 +6,7 @@ import { FieldError, useFormContext } from 'react-hook-form';
|
||||
import { FieldWrapper, FieldWrapperPassThroughProps } from './FieldWrapper';
|
||||
|
||||
type RadioItem = {
|
||||
label: ReactText;
|
||||
label: ReactNode;
|
||||
value: ReactText;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
@ -105,6 +105,12 @@ const webpackConfiguration = {
|
||||
...commonConfig.assetsRules,
|
||||
],
|
||||
},
|
||||
node: {
|
||||
module: 'empty',
|
||||
fs: 'empty',
|
||||
net: 'empty',
|
||||
child_process: 'empty',
|
||||
},
|
||||
resolve: {
|
||||
modules: ['src', 'node_modules'],
|
||||
extensions: ['.json', '.js', '.jsx', '.mjs', '.ts', '.tsx'],
|
||||
|
215
rfcs/openapi-to-hasura-single-action.md
Normal file
215
rfcs/openapi-to-hasura-single-action.md
Normal file
@ -0,0 +1,215 @@
|
||||
# Introduction
|
||||
|
||||
We want to translate OpenAPI specifications into GraqhQL to facilitate the creation of actions. The translation process presents several issues; you can explore them in this paper: [https://arxiv.org/abs/1809.08319](https://arxiv.org/abs/1809.08319).
|
||||
|
||||
I find it interesting to point out the following aspects of the translation process:
|
||||
- **deduplication of input type names**: in GraphQL, it is convenient to have shared input types across operations, but in an OpenAPI specification, you could define the same type multiple times across operations. The proposed approach is to deduplicate input types by making a deep comparison between them.
|
||||
- **sanitization of names**: In GraphQL, names must follow the following regular expression n /[_A-Za-z][_0-9A-Za-z]*/, whereas in OpenAPI this is not the case. The proposed approach uses a name sanitization function, which is important to consider when implementing the call translation process. In our case, the configuration of the actions
|
||||
|
||||
The authors of the paper also tested their implementation (the one we used) against many OpenAPI specifications, and they found those results:
|
||||
|
||||
- 97% of the OpenAPI specifications can be translated into GraphQL
|
||||
- 27.1% of the OpenAPI specifications can be translated into GraphQL when strict mode is enabled (without any warning)
|
||||
|
||||
The errors/warings they found are the following:
|
||||
|
||||
- **Name sanitization errors**: mostly due to the translation of boolean enum values (true and false are not valid GraphQL enum values)
|
||||
- **Invalid OAS**: the input OpenAPI specification could not be successfully validated.`
|
||||
- **Missing ref**: the input OpenAPI specification contains a reference to a schema that could not be found.
|
||||
|
||||
In addition, I will report the most common problems that we found when translating OpenAPI operations to Hasura Actions:
|
||||
- **Wrong response type**: the response type in the OpenAPI specification is missing or is wrong. An additional problem is when the success response is defined as `default` instead of `200`.
|
||||
- **Missing operations**: operations that cannot be translated are missing in the translation output.
|
||||
- **Failing OpenAPI specification**: a few number of OpenAPI specifications are not valid, and they cannot be translated at all.
|
||||
- **Forms**: some operations require the input to be passed as a form, which is not supported by Hasura Actions.
|
||||
- **OpenAPI 2.0 support**: the OpenAPI 2.0 specifications must be translated to OpenAPI 3.0 before being translated to GraphQL. This sometimes leads to errors in the translation process.
|
||||
- **Array query Params**: when array inputs must be serialized as query parameters, there isn't a kriti function that let you to do that
|
||||
|
||||
In this document, we explore our translation proposal directly into the console. We want to create a tool for importing a single action. The input is the GraphQL specification and the operation's name to translate, and the result is the complete pre-compilation of the action creation form.
|
||||
|
||||
# Tools selection
|
||||
|
||||
We need several operations to generate Hasura actions from OpenAPI specifications.
|
||||
|
||||
- **parsing** of the OpenAPI specification
|
||||
- **translating** the OpenAPI specification into GraphQL
|
||||
- **manipulating** the GraphQL schema.
|
||||
|
||||
For the first two points, the choice seems inevitable to fall on the `openapi-to-graphql` library [https://github.com/IBM/openapi-to-graphql](https://github.com/IBM/openapi-to-graphql), created by the authors of the article cited above.
|
||||
|
||||
An alternative might be swagger-to-graphql [https://github.com/yarax/swagger-to-graphql](https://github.com/yarax/swagger-to-graphql). However, this project is much less widely used [https://npmtrends.com/openapi-to-graphql-vs-swagger-to-graphql](https://npmtrends.com/openapi-to-graphql-vs-swagger-to-graphql) and is less robust than the other [https://npmcompare.com/compare/openapi-to-graphql,swagger-to-graphql](https://npmcompare.com/compare/openapi-to-graphql,swagger-to-graphql)
|
||||
|
||||
For the third point, the choice fell on `microfiber` [(https://github.com/anvilco/graphql-introspection-tools)](https://github.com/anvilco/graphql-introspection-tools), a tool for manipulating GraphQL schemas. There does not seem to be any other tool that performs this task [https://graphql.org/code/#javascript-tools](https://graphql.org/code/#javascript-tools)
|
||||
|
||||
The bundle size (minified + gzipped) for the two tools is 6.2kb for microfiber [https://bundlephobia.com/package/microfiber@1.3.1](https://bundlephobia.com/package/microfiber@1.3.1) and 331kb for openapi-to-graphql [https://bundlephobia.com/package/openapi-to-graphql@2.6.3](https://bundlephobia.com/package/openapi-to-graphql@2.6.3). We could consider checking if tree-shaking is available or maintaining a lighter version ourselves for the latter.
|
||||
|
||||
# Openapi to Hasura action
|
||||
|
||||
To achieve complete action, we need to:
|
||||
- Generate the GraphQL types:
|
||||
- for the action
|
||||
- for the types used by the action
|
||||
- Generate the action configuration:
|
||||
- the payload of the request
|
||||
- the http method
|
||||
- the path parameters of the URL
|
||||
- the parameters to pass in the query string\
|
||||
- the response transformation
|
||||
|
||||
As an example, we will simulate the generation of the `updatePet` action of the [https://petstore3.swagger.io/api/v3/openapi.json](https://petstore3.swagger.io/api/v3/openapi.json) specification.
|
||||
|
||||
## Generate Graphql types
|
||||
|
||||
The process of generating Graphql types is the following:
|
||||
1. Translate the OpenAPI spec into a GraphQL schema using openapi-to-graphql
|
||||
2. Removing from the schema all the operations but the selected one using microfiber
|
||||
print the resulting schema
|
||||
3. dividing action definition (everything withing type Query {} or type Mutation {} ) from type definition (anything else)
|
||||
4. We should note that we cannot translate all operations in the openapi specification to GraphQL. In this case, the openapi-to-graphql librate will exclude these operations, which will not be among those selectable for translation.
|
||||
|
||||
The schema generated by openapi-to-graphql, after point 2, is the following:
|
||||
|
||||
```graphql
|
||||
scalar BigInt
|
||||
|
||||
type Category {
|
||||
id: BigInt
|
||||
name: String
|
||||
}
|
||||
|
||||
input CategoryInput {
|
||||
id: BigInt
|
||||
name: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
updatePet(petInput: PetInput!): Pet
|
||||
}
|
||||
|
||||
type Pet {
|
||||
category: Category
|
||||
id: BigInt
|
||||
name: String!
|
||||
photoUrls: [String]!
|
||||
status: Status
|
||||
tags: [Tag]
|
||||
}
|
||||
|
||||
input PetInput {
|
||||
category: CategoryInput
|
||||
id: BigInt
|
||||
name: String!
|
||||
photoUrls: [String]!
|
||||
status: Status
|
||||
tags: [TagInput]
|
||||
}
|
||||
|
||||
type Query
|
||||
|
||||
enum Status {
|
||||
available
|
||||
pending
|
||||
sold
|
||||
}
|
||||
|
||||
type Tag {
|
||||
id: BigInt
|
||||
name: String
|
||||
}
|
||||
|
||||
input TagInput {
|
||||
id: BigInt
|
||||
name: String
|
||||
}
|
||||
```
|
||||
|
||||
then, since updatePet is a `POST` operation, the action is defined in the `Mutation` type. We can extract the action definition of the `updatePet` action by removing all the types except `Mutation`.
|
||||
|
||||
```graphql
|
||||
type Mutation {
|
||||
updatePet(petInput: PetInput!): Pet
|
||||
}
|
||||
```
|
||||
|
||||
the remaining are the types used by the action. We can extract them by removing all the types except `Query` and `Mutation`.
|
||||
|
||||
## Deal with translation errors
|
||||
|
||||
While `openapi-to-graphql` sometimes fails, we could try a best-effort approach to fix those errors in the original specification and then translate it again. The proposal approach is to make some midifications to the OpenAPI specification before translating it to GraphQL. We can have two possible outcomes
|
||||
|
||||
- The action is modified in such a way that it can be translated to GraphQL. In this case, we can generate the action.
|
||||
- The action is discarded so the other actions can be translated.
|
||||
|
||||
The errors type and corresponding solutions are the following:
|
||||
|
||||
### Boolean enum types
|
||||
|
||||
GraphQL does not support boolean enum types. Since this is most likely done to restrict the values to only true or false (e.g., in some API the `deleted` response can be only true), we can **replace the enum type with a boolean type**, and translate the action successfully.
|
||||
|
||||
### Empty types
|
||||
|
||||
GraphQL does not support empty types, which sometimes are present in the OpenAPI specification. We can **change the empty response to another type (e.g. string) or create a fake non-empty object type with a nullable fake field** and translate the action successfully.
|
||||
|
||||
###
|
||||
|
||||
## Generate Hasura action configuration
|
||||
|
||||
We can derive all the Hasura action configurations by the openapi-to-graphql metadata for the selected operation in a straightforward way.
|
||||
|
||||
- the **request and response transformations** are discussed in the section below.
|
||||
- the **http method** is got directly from the openapi-to-graphql metadata
|
||||
- the **base URL** of the API is got from the server section of the openapi specification
|
||||
- the **operation URL** is the path of the operation in the metadata
|
||||
- the **path parameters** of the URL is the list of arguments that are marked as path parameters in the metadata
|
||||
- the parameters to pass in the **query string**: is the list of arguments that are marked as query parameters in the metadata
|
||||
### Request and response transormation
|
||||
|
||||
If there were a one-to-one relationship between REST and GraphQL types, there would be no need for any request or response transformation. But, as is stated in the IBM article, to generate GrahpQL types, some names could be sanitized and hence be different from the REST ones. This could lead to broken Hasura action calls.
|
||||
|
||||
To solve this problem, a layer of request and response transformation is needed to perform the translation of types between the REST and GraphQL worlds.
|
||||
|
||||
While in the article this is is done in the generated resolvers, in Hasura action kriti templates must be generated by recursively traversing the GraphQL schema and the OpenAPI specification and used as request and response transformation.
|
||||
|
||||
This is an example of `PetInput` request and response kriti transformation. We artificially renamed the `name` field to in OpenAPI spefication to `$name` to simulate the incompatibility.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": {{$body.input.petInput?.id}},
|
||||
"$name": {{$body.input.petInput?.name}},
|
||||
"category": {
|
||||
"id": {{$body.input.petInput?.category?.id}},
|
||||
"name": {{$body.input.petInput?.category?.name}}
|
||||
},
|
||||
"photoUrls": {{$body.input.petInput?.photoUrls}},
|
||||
"tags": {{if inverse(empty($body.input.petInput?.tags))}} {{ range _, tags := $body.input.petInput?.tags}} {
|
||||
"id": {{tags?.id}},
|
||||
"name": {{tags?.name}}
|
||||
} {{end}} {{else}} null {{end}},
|
||||
"status": {{$body.input.petInput?.status}}
|
||||
}
|
||||
```
|
||||
|
||||
and the opposite:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": {{$body?.id}},
|
||||
"name": {{$body?.$name}},
|
||||
"category": {
|
||||
"id": {{$body?.category?.id}},
|
||||
"name": {{$body?.category?.name}}
|
||||
},
|
||||
"photoUrls": {{$body?.photoUrls}},
|
||||
"tags": {{if inverse(empty($body?.tags))}} {{ range _, tags := $body?.tags}} {
|
||||
"id": {{tags?.id}},
|
||||
"name": {{tags?.name}}
|
||||
} {{end}} {{else}} null {{end}},
|
||||
"status": {{$body?.status}}
|
||||
}
|
||||
```
|
||||
|
||||
note how objects and arrays are handled.
|
||||
|
||||
## Authentication
|
||||
|
||||
OpenAPI specification allows to specify authentication methods in the security section and should be managed case by case. In the paper, IBM folks manage authentication through GraphQL Viewers. In our case, for the first release, we will enable the flag `Forward client headers to webhook,` which will probably be enough for most cases (e.g., users can pass the JWT token in the headers).
|
Loading…
Reference in New Issue
Block a user