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:
Daniele Cammareri 2022-11-29 20:08:32 +01:00 committed by hasura-bot
parent 32a316aef7
commit 16561d2c9a
22 changed files with 3598 additions and 540 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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

View File

@ -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 {};
};

View File

@ -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}

View File

@ -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>
);
};

View File

@ -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();
};

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
declare module 'microfiber';

View File

@ -0,0 +1,2 @@
export { OasGeneratorModal } from './OASGeneratorModal';
export { GeneratedAction } from './types';

View File

@ -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"
}
}
}
}
}
}

View File

@ -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'];

View File

@ -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();
});
});

View File

@ -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);
};

View File

@ -0,0 +1 @@
export * from './components/OASGeneratorModal/';

View File

@ -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] : []),
];

View File

@ -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>> = ({

View File

@ -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"

View File

@ -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;
};

View File

@ -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'],

View 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).