mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
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:
parent
2d839548af
commit
3f113802cb
@ -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'
|
||||
);
|
||||
}}
|
||||
|
@ -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>,
|
||||
|
@ -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);
|
||||
|
@ -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[];
|
||||
};
|
||||
}
|
||||
|
@ -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}}&"]) }}'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user