console: add support for x-www-form-urlencoded payload in import from OpenAPI

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8781
GitOrigin-RevId: 81b79b1fe0e16024703c2cbc9df53ebb81f9cfb4
This commit is contained in:
Daniele Cammareri 2023-05-05 11:50:05 +02:00 committed by hasura-bot
parent 2d839548af
commit 3f113802cb
6 changed files with 190 additions and 56 deletions

View File

@ -5,6 +5,7 @@ import React from 'react';
import { FaChevronDown, FaExternalLinkAlt } from 'react-icons/fa';
import { Operation } from './types';
import globals from '../../../../Globals';
import { normalizeOperationId } from './utils';
export interface OasGeneratorActionsProps {
operation: Operation;
@ -51,7 +52,9 @@ export const OasGeneratorActions: React.FC<
size="sm"
onClick={e => {
window.open(
`${globals.urlPrefix}/actions/manage/${operation.operationId}/modify`,
`${globals.urlPrefix}/actions/manage/${normalizeOperationId(
operation.operationId
)}/modify`,
'_blank'
);
}}

View File

@ -1,5 +1,6 @@
import React, { ReactNode } from 'react';
import YAML from 'js-yaml';
import last from 'lodash/last';
import { CardedTable } from '../../../../new-components/CardedTable';
import { DropdownButton } from '../../../../new-components/DropdownButton';
import { CodeEditorField, InputField } from '../../../../new-components/Form';
@ -15,7 +16,12 @@ import { hasuraToast } from '../../../../new-components/Toasts';
import { OasGeneratorActions } from './OASGeneratorActions';
import { GeneratedAction, Operation } from '../OASGenerator/types';
import { generateAction, getOperations } from '../OASGenerator/utils';
import {
generateAction,
getOperations,
isOasError,
normalizeOperationId,
} from '../OASGenerator/utils';
import { UploadFile } from './UploadFile';
const fillToTenRows = (data: ReactNode[][]) => {
@ -75,7 +81,8 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
!search ||
op.operationId.toLowerCase().includes(search.toLowerCase()) ||
op.path.toLowerCase().includes(search.toLowerCase()) ||
op.method.toLowerCase().includes(search.toLowerCase());
op.method.toLowerCase().includes(search.toLowerCase()) ||
op.description?.toLowerCase().includes(search.toLowerCase());
const methodMatch =
selectedMethods.length === 0 ||
selectedMethods.includes(op.method.toUpperCase());
@ -101,15 +108,19 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
try {
localParsedOas = JSON.parse(oas) as Oas3;
// if oas is smaller that 3mb
if (oas.length < 1024 * 1024 * 1) {
if (oas.length < 1024 * 1024 * 3) {
setIsOasTooBig(false);
if (props.saveOas) {
props.saveOas(oas);
}
} else {
setIsOasTooBig(true);
if (props.saveOas) {
props.saveOas('');
}
}
} catch (e) {
console.error('ImportOAS/OAS_PARSE_ERROR', e);
try {
localParsedOas = YAML.load(oas) as Oas3;
} catch (e2) {
@ -150,9 +161,23 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
);
}
} catch (e) {
setError('oas', {
message: `Invalid spec: ${(e as Error).message}`,
});
const error = e as Error;
console.error('ImportOAS/INVALID_SPEC_ERROR', e);
if (isOasError(error)) {
const paths =
error.options?.context
.slice()
.map((path: string) =>
path.replace(/~1/g, '/').replace(/\/\//g, '/')
) ?? [];
setError('oas', {
message: `Invalid spec: ${error.message} at ${last(paths)}`,
});
} else {
setError('oas', {
message: `Invalid spec: ${error.message} `,
});
}
}
setParsedOas(localParsedOas ?? null);
@ -205,7 +230,7 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
title: 'Failed to generate action',
message: (e as Error).message,
});
console.error(e);
console.error('ImportOAS/CREATE_ACTION_ERROR/', e);
}
}
};
@ -314,7 +339,8 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
const isActionAlreadyCreated =
metadata?.metadata?.actions?.some(
action =>
action.name.toLowerCase() === op.operationId.toLowerCase()
action.name.toLowerCase() ===
normalizeOperationId(op.operationId).toLowerCase()
);
return [
<Badge
@ -328,7 +354,9 @@ export const OasGeneratorForm = (props: OasGeneratorFormProps) => {
existing={isActionAlreadyCreated}
operation={op}
onCreate={() => createAction(op.operationId)}
onDelete={() => onDelete(op.operationId)}
onDelete={() =>
onDelete(normalizeOperationId(op.operationId))
}
disabled={disabled}
/>
</div>,

View File

@ -90,7 +90,7 @@ export const OASGeneratorPage = () => {
const onDelete = (actionName: string) => {
const action = metadata?.metadata?.actions?.find(
a => a.name === actionName
a => a.name.toLowerCase() === actionName.toLowerCase()
);
if (action) {
setBusy(true);

View File

@ -4,6 +4,17 @@ import z from 'zod';
import { formSchema } from './OASGeneratorPage';
export type SchemaType = z.infer<typeof formSchema>;
export type RequestTransform =
| {
type: 'json';
value: string;
}
| {
type: 'x-www-form-urlencoded';
value: Record<string, string>;
};
export type GeneratedAction = {
operationId: string;
actionType: 'query' | 'mutation';
@ -13,7 +24,7 @@ export type GeneratedAction = {
method: RequestTransformMethod;
baseUrl: string;
path: string;
requestTransforms: string;
requestTransforms?: RequestTransform;
responseTransforms: string;
sampleInput: string;
headers: string[];
@ -27,3 +38,10 @@ export type DataDefinition = Operation['responseDefinition'];
export type SubDefinition = Operation['responseDefinition']['subDefinitions'];
export type OperationParameters = Operation['parameters'];
export interface OASError extends Error {
message: string;
options: {
context: string[];
};
}

View File

@ -70,7 +70,7 @@ describe('generateAction', () => {
method: 'GET',
baseUrl: 'http://petstore.swagger.io/api',
path: '/pets',
requestTransforms: '',
requestTransforms: undefined,
responseTransforms: '',
sampleInput: JSON.stringify(
{
@ -87,7 +87,7 @@ describe('generateAction', () => {
),
headers: [],
queryParams:
'{{ concat ([concat({{ range _, x := $body.input.tags }} "tags={{x}}&" {{ end }}), "limit={{$body.input.limit}}&"]) }}',
'{{ concat ([concat({{ range _, x := $body.input?.tags }} "tags={{x}}&" {{ end }}), "limit={{$body.input?.limit}}&"]) }}',
});
});
@ -102,19 +102,19 @@ describe('generateQueryParams', () => {
it('should generate query params with one non-array param', async () => {
const queryParams = await generateQueryParams([status]);
expect(queryParams).toStrictEqual([
{ name: 'status', value: '{{$body.input.status}}' },
{ name: 'status', value: '{{$body.input?.status}}' },
]);
});
it('should generate query params with one non-array param and one array param', async () => {
const queryParams = await generateQueryParams([status, tags]);
expect(queryParams).toBe(
'{{ concat (["status={{$body.input.status}}&", concat({{ range _, x := $body.input.tags }} "tags={{x}}&" {{ end }})]) }}'
'{{ concat (["status={{$body.input?.status}}&", concat({{ range _, x := $body.input?.tags }} "tags={{x}}&" {{ end }})]) }}'
);
});
it('should generate query params with one non-array param and one array param (reversed)', async () => {
const queryParams = await generateQueryParams([tags, status]);
expect(queryParams).toBe(
'{{ concat ([concat({{ range _, x := $body.input.tags }} "tags={{x}}&" {{ end }}), "status={{$body.input.status}}&"]) }}'
'{{ concat ([concat({{ range _, x := $body.input?.tags }} "tags={{x}}&" {{ end }}), "status={{$body.input?.status}}&"]) }}'
);
});
});

View File

@ -25,11 +25,14 @@ import { getActionRequestSampleInput } from '../../../../components/Services/Act
import {
DataDefinition,
GeneratedAction,
OASError,
Operation,
OperationParameters,
Result,
SubDefinition,
} from './types';
import { RequestTransformBody } from '../../../../metadata/types';
import camelCase from 'lodash/camelCase';
const parseRequestMethod = (method: string): RequestTransformMethod => {
switch (method.toLowerCase()) {
@ -74,9 +77,9 @@ export const generateQueryParams = (parameters: OperationParameters) => {
if (isThereArray) {
const stringParams = parameters.map(param => {
if (isSchemaObject(param?.schema) && param.schema.type === 'array') {
return `concat({{ range _, x := $body.input.${param.name} }} "${param.name}={{x}}&" {{ end }})`;
return `concat({{ range _, x := $body.input?.${param.name} }} "${param.name}={{x}}&" {{ end }})`;
}
return `"${param.name}={{$body.input.${param.name}}}&"`;
return `"${param.name}={{$body.input?.${param.name}}}&"`;
});
return `{{ concat ([${stringParams.join(', ')}]) }}`.replace(/&&/, '&');
@ -87,13 +90,11 @@ export const generateQueryParams = (parameters: OperationParameters) => {
?.map(param => param.name) || [];
return parameterNames.map(name => ({
name,
value: `{{$body.input.${name}}}`,
value: `{{$body.input?.${name}}}`,
}));
};
const lowerCaseFirstLetter = (str: string): string => {
return str ? str.charAt(0).toLowerCase() + str.slice(1) : '';
};
export const normalizeOperationId = camelCase;
interface Transform {
transform: Record<string, unknown>;
@ -134,13 +135,11 @@ const createTransform = (
let needTransform = false;
const transform = Object.entries(definition).reduce((acc, curr) => {
const name = curr[0];
const value = curr[1] as DataDefinition;
const keyFrom = inverse
? lowerCaseFirstLetter(value.preferredName)
: curr[0];
const keyTo = inverse
? curr[0]
: lowerCaseFirstLetter(value.preferredName);
const keyFrom = inverse ? normalizeOperationId(name) : curr[0];
const keyTo = inverse ? curr[0] : normalizeOperationId(name);
if (keyFrom !== keyTo) {
needTransform = true;
}
@ -199,8 +198,9 @@ const createSampleInput = (
}
return Object.entries(definition).reduce((acc, curr) => {
const name = curr[0];
const value = curr[1] as DataDefinition;
const keyFrom = lowerCaseFirstLetter(value.preferredName);
const keyFrom = normalizeOperationId(name);
const keyTo = curr[0];
if (
@ -261,30 +261,75 @@ const postProcessTransform = (output: Transform): string | null => {
return string;
};
export const createRequestTransform = (operation: Operation): string => {
const createApplicationJSONRequestTransform = (
operation: Operation,
inputName: string
): GeneratedAction['requestTransforms'] => {
const defaultRequestTransform = ['POST', 'PUT', 'PATCH'].includes(
operation.method.toUpperCase()
)
? `{{$body.input.${inputName}}}`
: '';
if (operation.payloadDefinition?.subDefinitions) {
return {
type: 'json',
value:
postProcessTransform(
createTransform(
operation.payloadDefinition.subDefinitions,
`$body.input.${inputName}`,
false
)
) ?? defaultRequestTransform,
};
}
return {
type: 'json',
value: '',
};
};
const createXWWWFormURLEncodedRequestTransform = (
operation: Operation,
inputName: string
): GeneratedAction['requestTransforms'] => {
if (operation.payloadDefinition?.subDefinitions) {
return {
type: 'x-www-form-urlencoded',
value: Object.entries(operation.payloadDefinition.subDefinitions).reduce(
(acc, curr) => {
const key = curr[0];
return {
...acc,
[key]: `{{$body.input.${inputName}?.${normalizeOperationId(key)}}}`,
};
},
{} as Record<string, string>
),
};
}
return {
type: 'x-www-form-urlencoded',
value: {},
};
};
export const createRequestTransform = (
operation: Operation
): GeneratedAction['requestTransforms'] | null => {
let inputName = '';
const inputObjectType = operation.payloadDefinition?.graphQLInputObjectType;
if (inputObjectType && 'name' in inputObjectType) {
inputName = lowerCaseFirstLetter(inputObjectType.name);
inputName = normalizeOperationId(inputObjectType.name);
if (operation.payloadContentType === 'application/x-www-form-urlencoded') {
return createXWWWFormURLEncodedRequestTransform(operation, inputName);
}
return createApplicationJSONRequestTransform(operation, inputName);
}
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 '';
return null;
};
export const createResponseTransform = (operation: Operation): string => {
if (operation.responseDefinition?.targetGraphQLType === 'string') {
return '{{$body}}';
@ -425,7 +470,7 @@ export const translateAction = (
/\{([^}]+)\}/g,
(_, p1) => `{{$body.input.${p1[0].toLowerCase()}${p1.slice(1)}}}`
),
requestTransforms: createRequestTransform(operation) ?? '',
requestTransforms: createRequestTransform(operation) ?? undefined,
responseTransforms: createResponseTransform(operation) ?? '',
sampleInput: JSON.stringify(sampleInput, null, 2),
headers,
@ -437,24 +482,34 @@ const applyWorkarounds = (properties: (SchemaObject | ReferenceObject)[]) => {
// eslint-disable-next-line no-restricted-syntax
for (const property of Object.values(properties ?? {})) {
if (!('$ref' in property)) {
delete property.default;
// fix boolean enum issue
if (property.type === 'boolean') {
delete property.enum;
}
// fix null enum issue
if (property.type === 'string' && property.enum) {
property.enum = property.enum?.filter(v => v !== null);
}
// fix boolean enum issue
if (
property.type === 'string' &&
(property.enum || []).some(v => v === 'true' || v === 'false')
) {
delete property.enum;
}
// fix empty type issue
if (
property.type === 'object' &&
JSON.stringify(property.properties) === '{}'
) {
// fix empty type issue
property.type = 'string';
delete property.properties;
}
// fix array with no items issue
if (property.type === 'array' && !('items' in property)) {
property.items = { type: 'string' };
}
if (property.properties) {
applyWorkarounds(Object.values(property.properties));
}
@ -466,6 +521,13 @@ 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));
Object.values(oasCopy?.paths ?? {}).forEach(path => {
path.get?.parameters?.forEach(param => {
if ('schema' in param && param.schema) {
applyWorkarounds([param.schema]);
}
});
});
}
return createGraphQLSchema(oasCopy, {
@ -473,6 +535,9 @@ const parseOas = async (oas: Oas2 | Oas3): Promise<Result> => {
operationIdFieldNames: true,
simpleEnumValues: true,
viewer: false,
oasValidatorOptions: {
warnOnly: true,
},
});
};
@ -509,6 +574,26 @@ type ActionState = {
comment: string;
};
const generateRequestTransformBody = (
requestTransform: GeneratedAction['requestTransforms']
): RequestTransformBody | undefined => {
if (requestTransform?.type === 'json') {
return {
action: 'transform',
template: requestTransform.value,
};
}
if (requestTransform?.type === 'x-www-form-urlencoded') {
return {
action: 'x_www_form_urlencoded',
form_template: requestTransform.value,
};
}
return undefined;
};
export const generatedActionToHasuraAction = (
generatedAction: GeneratedAction
): {
@ -526,7 +611,7 @@ export const generatedActionToHasuraAction = (
},
headers: generatedAction.headers.map(name => ({
name,
value: `{{$body.input.${name}}}`,
value: `{{$body.input?.${name}}}`,
type: 'static',
})),
forwardClientHeaders: true,
@ -554,10 +639,7 @@ export const generatedActionToHasuraAction = (
...(generatedAction.requestTransforms
? {
body: {
action: 'transform',
template: generatedAction.requestTransforms,
},
body: generateRequestTransformBody(generatedAction.requestTransforms),
}
: {}),
};
@ -580,3 +662,6 @@ export const generatedActionToHasuraAction = (
responseTransform,
};
};
export const isOasError = (error: Error): error is OASError =>
'options' in error;