Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add form-data format with string, file and file Array #43

Merged
merged 4 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/includer/traverse/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@
}

const downCallstack = callstack.concat(value);
const type = inferType(value);

Check warning on line 323 in src/includer/traverse/tables.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Function 'prepareSampleElement' has a complexity of 21. Maximum allowed is 20

const schema = findNonNullOneOfElement(value);

Expand All @@ -346,6 +346,8 @@
return 'c3073b9d-edd0-49f2-a28d-b7ded8ff9a8b';
case 'date-time':
return '2022-12-29T18:02:01Z';
case 'binary':
return null;
default:
return 'string';
}
Expand Down
5 changes: 4 additions & 1 deletion src/includer/ui/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@
const searchParams = params?.filter((param: Parameter) => param.in === 'query');
const headers = params?.filter((param: Parameter) => param.in === 'header');
let bodyStr: null | string = null;
if (requestBody?.type === 'application/json') {

if (requestBody?.type === 'application/json' || requestBody?.type === 'multipart/form-data') {
bodyStr = JSON.stringify(prepareSampleObject(requestBody?.schema ?? {}), null, 2);
}

Expand All @@ -119,6 +120,8 @@
searchParams,
headers,
body: bodyStr,
schema: requestBody?.schema ?? {},
bodyType: requestBody?.type,
method,
security,
path: path,
Expand Down Expand Up @@ -213,7 +216,7 @@
const {type = 'schema', schema} = obj;
const sectionTitle = title(3)('Body');

let result: any[] = [sectionTitle];

Check warning on line 219 in src/includer/ui/endpoint.ts

View workflow job for this annotation

GitHub Actions / build (18.x)

Unexpected any. Specify a different type

if (isPrimitive(schema.type)) {
result = [
Expand Down
158 changes: 158 additions & 0 deletions src/runtime/components/BodyFormData.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import React from 'react';
import {Text, TextArea} from '@gravity-ui/uikit';
import {JSONSchema6Definition} from 'json-schema';

import {Text as TextEnum} from '../../plugin/constants';
import {OpenJSONSchema} from '../../includer/models';

import type {Field, Nullable} from '../types';
import {Column} from './Column';
import {FileInputArray} from './FileInputArray';

type Props = {
example: Nullable<string>;
schema: OpenJSONSchema | undefined;
bodyType?: string;
};

type State = {
error: Nullable<string>;
};

export class BodyFormData extends React.Component<Props, State> implements Field<FormData, string> {
private formValue: FormData;
constructor(props: Props) {
super(props);
this.formValue = new FormData();

this.state = {
error: null,
};
}

renderInput(key: string) {
return (
<Column gap={2}>
<Text variant="body-2">{key}:</Text>
<input
type="file"
onChange={(event) => {
this.createOnChange(key)(event.target.files?.[0]);
}}
/>
</Column>
);
}

renderFileInput(key: string) {
return (
<Column gap={2}>
<Text variant="body-2">{key}:</Text>
<FileInputArray onChange={this.createOnChange(key)} />
</Column>
);
}

renderTextArea(key: string, property: OpenJSONSchema) {
const example = JSON.parse(this.props.example ?? '{}');

const exampleValue =
property.type === 'string' ? example[key] : JSON.stringify(example[key], null, 2);

const rows = property.type === 'string' ? 1 : 3;

return (
<Column gap={2}>
<Text variant="body-2">{key}:</Text>
<TextArea
onUpdate={this.createOnChange(key)}
defaultValue={exampleValue}
rows={rows}
/>
</Column>
);
}

renderProperty(key: string, property: JSONSchema6Definition) {
if (typeof property !== 'object') {
return null;
}

if (property.type === 'string' && property.format === 'binary') {
return this.renderInput(key);
}

const {items} = property;

if (property.type === 'array' && typeof items === 'object' && !Array.isArray(items)) {
const {format, type} = items;

if (type === 'string' && format === 'binary') {
return this.renderFileInput(key);
}
// TODO: string array
}

return this.renderTextArea(key, property);
}

render() {
const {properties, type} = this.props.schema ?? {};

if (type !== 'object' || !properties || this.props.bodyType !== 'multipart/form-data') {
return null;
}

const entries = Object.entries(properties);

return (
<Column gap={10}>
<Text variant="header-1">{TextEnum.BODY_INPUT_LABEL}</Text>
{entries.map(([key, property]) => this.renderProperty(key, property))}
</Column>
);
}

createOnChange(fieldName: string) {
return (newValue: string | undefined | File | File[]) => {
if (!newValue) {
this.formValue.delete(fieldName);

return;
}

if (typeof newValue === 'string') {
this.formValue.set(fieldName, newValue);

return;
}

if (Array.isArray(newValue)) {
this.formValue.delete(fieldName);
for (const item of newValue) {
this.formValue.append(fieldName, item);
}

return;
}

this.formValue.set(fieldName, newValue);
};
}

validate() {
const error = this.isRequired && !this.value ? 'Required' : undefined;

this.setState({error});

return error;
}

value() {
return this.formValue;
}

private get isRequired() {
return this.props.example !== undefined && this.props.example !== null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import {Column} from './Column';

type Props = {
value: Nullable<string>;
bodyType?: string;
};

type State = {
error: Nullable<string>;
value: Nullable<string>;
};

export class Body extends React.Component<Props, State> implements Field<string, string> {
export class BodyJson extends React.Component<Props, State> implements Field<string, string> {
constructor(props: Props) {
super(props);

Expand All @@ -28,7 +29,7 @@ export class Body extends React.Component<Props, State> implements Field<string,
render() {
const {error, value} = this.state;

if (value === undefined || value === null) {
if (value === undefined || value === null || this.props.bodyType !== 'application/json') {
return null;
}

Expand Down
74 changes: 74 additions & 0 deletions src/runtime/components/FileInputArray.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React, {useCallback, useRef, useState} from 'react';
import {Button} from '@gravity-ui/uikit';
import {Column} from './Column';

const isFile = (item: undefined | File): item is File => item !== undefined;

type Props = {
onChange(value: File[]): void;
}

Check failure on line 9 in src/runtime/components/FileInputArray.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Insert `;`

type IndexedFiles = Record<number, File | undefined>;

export const FileInputArray: React.FC<Props> = ({onChange}) => {
const ref = useRef(1);
const [inputs, setInputs] = useState<IndexedFiles>({});

const createOnChange = useCallback(
(idWithChange: number): React.ChangeEventHandler<HTMLInputElement> =>
(event) => {
setInputs((oldState) => {
const file = event.target.files?.[0];
const nextState = {...oldState, [idWithChange]: file }

Check failure on line 22 in src/runtime/components/FileInputArray.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Replace `·}` with `};`

onChange(Object.values(nextState).filter(isFile));

return nextState;
});
},
[setInputs],
);

const onAdd = useCallback(() => {
setInputs((prevState) => ({...prevState, [ref.current++]: undefined }));

Check failure on line 33 in src/runtime/components/FileInputArray.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Delete `·`
}, [setInputs, ref]);

const createOnRemove = useCallback(
(idForRemove: number) => () => {
setInputs((oldState) => {
delete oldState[idForRemove];

return oldState;
});
},
[setInputs],
);

return (
<Column gap={3}>
{Object.keys(inputs).map((id, index) => {
return (
<div key={id}>
<input type="file" onChange={createOnChange(index)} />
{Boolean(index) && (
<Button
view="normal"
size="s"
type="button"
onClick={createOnRemove(Number(id))}
>
-
</Button>
)}
</div>
);
})}
<div>
<Button view="normal" size="s" type="button" onClick={onAdd}>
Add file
</Button>
</div>
</Column>
);
};

Check failure on line 74 in src/runtime/components/FileInputArray.tsx

View workflow job for this annotation

GitHub Actions / build (18.x)

Delete `⏎`
3 changes: 2 additions & 1 deletion src/runtime/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export {Column} from './Column';
export {Params} from './Params';
export {Body} from './Body';
export {BodyJson} from './BodyJson';
export {BodyFormData} from './BodyFormData';
export {Response} from './Response';
export {Error} from './Error';
export {Loader} from './Loader';
Expand Down
21 changes: 16 additions & 5 deletions src/runtime/sandbox.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {FormState, SandboxProps} from './types';
import React, {useRef, useState} from 'react';
import {Button} from '@gravity-ui/uikit';
import {Body, Column, Params, Result} from './components';
import {BodyFormData, BodyJson, Column, Params, Result} from './components';

import {Text, yfmSandbox} from '../plugin/constants';
import {collectErrors, collectValues, prepareHeaders, prepareRequest} from './utils';
Expand All @@ -14,7 +14,8 @@ export const Sandbox: React.FC<SandboxProps> = (props) => {
path: useRef(null),
search: useRef(null),
headers: useRef(null),
body: useRef(null),
bodyJson: useRef(null),
bodyFormData: useRef(null),
};
const [request, setRequest] = useState<Promise<Response> | null>(null);

Expand All @@ -26,7 +27,11 @@ export const Sandbox: React.FC<SandboxProps> = (props) => {
}

const values = collectValues(refs) as FormState;
const {url, headers, body} = prepareRequest((props.host ?? '') + '/' + props.path, values);
const {url, headers, body} = prepareRequest(
(props.host ?? '') + '/' + props.path,
values,
props.bodyType,
);

setRequest(
fetch(url, {
Expand Down Expand Up @@ -55,13 +60,19 @@ export const Sandbox: React.FC<SandboxProps> = (props) => {
title={Text.HEADER_PARAMS_SECTION_TITLE}
params={preparedHeaders}
/>
<Body ref={refs.body} value={props.body} />
{request && <Result request={request} />}
<BodyJson ref={refs.bodyJson} value={props.body} bodyType={props.bodyType} />
<BodyFormData
ref={refs.bodyFormData}
schema={props.schema}
example={props.body}
bodyType={props.bodyType}
/>
<div>
<Button size="l" view="action" type="submit">
{Text.BUTTON_SUBMIT}
</Button>
</div>
{request && <Result request={request} />}
</Column>
</form>
);
Expand Down
5 changes: 4 additions & 1 deletion src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export type FormState = {
path: Record<string, string>;
search: Record<string, string>;
headers: Record<string, string>;
body: string | undefined;
bodyJson?: string;
bodyFormData?: FormData;
};

export type ResponseState = {
Expand Down Expand Up @@ -40,6 +41,8 @@ export type SandboxProps = {
searchParams?: Parameters;
headers?: Parameters;
body?: string;
bodyType?: string;
schema?: OpenJSONSchema;
security?: Security[];
};

Expand Down
Loading
Loading