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: File Uploads as Request Body #2737

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { IHttpOperation } from '@stoplight/types';

export const httpOperation: IHttpOperation = {
id: '?http-operation-id?',
iid: 'POST_todos',
method: 'post',
path: '/todos',
summary: 'Upload File',
responses: [
{
id: '?http-response-200?',
code: '200',
},
],
servers: [
{
id: '?http-server-todos.stoplight.io?',
url: 'https://todos.stoplight.io',
},
],
request: {
body: {
id: '?http-request-body?',
contents: [
{
id: '?http-media-0?',
mediaType: 'application/octet-stream',
schema: {
type: 'string',
format: 'binary',
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
],
},
},
};

export default httpOperation;
52 changes: 52 additions & 0 deletions packages/elements-core/src/components/TryIt/Body/BinaryBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { SchemaTree } from '@stoplight/json-schema-tree';
import { Choice, useChoices } from '@stoplight/json-schema-viewer';
import { Panel } from '@stoplight/mosaic';
import { IMediaTypeContent } from '@stoplight/types';
import * as React from 'react';

import { FileUploadParameterEditor } from '../Parameters/FileUploadParameterEditors';
import { OneOfMenu } from './FormDataBody';
import { BodyParameterValues } from './request-body-utils';

export interface BinaryBodyProps {
specification?: IMediaTypeContent;
values: BodyParameterValues;
onChangeValues: (newValues: BodyParameterValues) => void;
}

export const BinaryBody: React.FC<BinaryBodyProps> = ({ specification, values, onChangeValues }) => {
const schema: any = React.useMemo(() => {
const schema = specification?.schema ?? {};
const tree = new SchemaTree(schema, { mergeAllOf: true, refResolver: null });
tree.populate();
return tree.root.children[0];
}, [specification]);

const { selectedChoice, choices, setSelectedChoice } = useChoices(schema);

const onSchemaChange = (choice: Choice) => {
// Erase existing values; the old and new schemas may have nothing in common.
onChangeValues({});
setSelectedChoice(choice);
};

return (
<Panel defaultIsOpen>
<Panel.Titlebar
rightComponent={<OneOfMenu choices={choices} choice={selectedChoice} onChange={onSchemaChange} />}
>
Body
</Panel.Titlebar>
<Panel.Content className="sl-overflow-y-auto ParameterGrid OperationParametersContent">
<FileUploadParameterEditor
key={'file'}
parameter={{ name: 'file' }}
value={values.file instanceof File ? values.file : undefined}
onChange={newValue => {
newValue ? onChangeValues({ file: newValue }) : onChangeValues({});
}}
/>
</Panel.Content>
</Panel>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import '@testing-library/jest-dom';

import { render } from '@testing-library/react';
import React from 'react';

import { BinaryBody, BinaryBodyProps } from '../BinaryBody';

describe('BinaryBody', () => {
it('renders file input when the form is application/octet-stream', () => {
const props: BinaryBodyProps = {
specification: {
id: '493afac014fa8',
mediaType: 'application/octet-stream',
encodings: [],
schema: {
type: 'string',
format: 'binary',
$schema: 'http://json-schema.org/draft-07/schema#',
},
},
values: {},
onChangeValues: () => {},
};

const { getByLabelText } = render(<BinaryBody {...props} />);

expect(getByLabelText('file')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ function isMultipartContent(content: IMediaTypeContent) {
return content.mediaType.toLowerCase() === 'multipart/form-data';
}

export const isBinaryContent = (content: IMediaTypeContent) => isApplicationOctetStream(content);

function isApplicationOctetStream(content: IMediaTypeContent) {
return content.mediaType.toLowerCase() === 'application/octet-stream';
}

export async function createRequestBody(
mediaTypeContent: IMediaTypeContent | undefined,
bodyParameterValues: BodyParameterValues | undefined,
Expand Down Expand Up @@ -74,16 +80,17 @@ const requestBodyCreators: Record<string, RequestBodyCreator | undefined> = {

export const useBodyParameterState = (mediaTypeContent: IMediaTypeContent | undefined) => {
const isFormDataBody = mediaTypeContent && isFormDataContent(mediaTypeContent);
const isBinaryBody = mediaTypeContent && isBinaryContent(mediaTypeContent);

const initialState = React.useMemo(() => {
if (!isFormDataBody) {
if (!isFormDataBody || isBinaryBody) {
return {};
}
const properties = mediaTypeContent?.schema?.properties ?? {};
const required = mediaTypeContent?.schema?.required;
const parameters = mapSchemaPropertiesToParameters(properties, required);
return initialParameterValues(parameters);
}, [isFormDataBody, mediaTypeContent]);
}, [isFormDataBody, isBinaryBody, mediaTypeContent]);

const [bodyParameterValues, setBodyParameterValues] = React.useState<BodyParameterValues>(initialState);
const [isAllowedEmptyValue, setAllowedEmptyValue] = React.useState<ParameterOptional>({});
Expand All @@ -98,15 +105,23 @@ export const useBodyParameterState = (mediaTypeContent: IMediaTypeContent | unde
setBodyParameterValues,
isAllowedEmptyValue,
setAllowedEmptyValue,
{ isFormDataBody: true, bodySpecification: mediaTypeContent! },
{ isFormDataBody: true, isBinaryBody: false, bodySpecification: mediaTypeContent! },
] as const;
} else if (isBinaryBody) {
return [
bodyParameterValues,
setBodyParameterValues,
isAllowedEmptyValue,
setAllowedEmptyValue,
{ isFormDataBody: false, isBinaryBody: true, bodySpecification: mediaTypeContent! },
] as const;
} else {
return [
bodyParameterValues,
setBodyParameterValues,
isAllowedEmptyValue,
setAllowedEmptyValue,
{ isFormDataBody: false, bodySpecification: undefined },
{ isFormDataBody: false, isBinaryBody: false, bodySpecification: undefined },
] as const;
}
};
45 changes: 45 additions & 0 deletions packages/elements-core/src/components/TryIt/TryIt.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
import fetchMock from 'jest-fetch-mock';
import * as React from 'react';

import { httpOperation as octetStreamOperation } from '../../__fixtures__/operations/application-octet-stream-post';
import { httpOperation as base64FileUpload } from '../../__fixtures__/operations/base64-file-upload';
import { examplesRequestBody, singleExampleRequestBody } from '../../__fixtures__/operations/examples-request-body';
import { headWithRequestBody } from '../../__fixtures__/operations/head-todos';
Expand Down Expand Up @@ -533,6 +534,50 @@ describe('TryIt', () => {
});
});

describe('Binary body', () => {
it('shows panel when there is file input', () => {
render(<TryItWithPersistence httpOperation={octetStreamOperation} />);

let parametersHeader = screen.queryByText('Body');
expect(parametersHeader).toBeInTheDocument();
});

it('displays file input correctly', () => {
render(<TryItWithPersistence httpOperation={octetStreamOperation} />);

const fileField = screen.getByRole('textbox', { name: 'file' }) as HTMLInputElement;

expect(fileField.placeholder).toMatch(/pick a file/i);
});

it('builds correct application/octet-stream request and send file in the body', async () => {
render(<TryItWithPersistence httpOperation={octetStreamOperation} />);

userEvent.upload(screen.getByLabelText('Upload'), new File(['something'], 'some-file'));

clickSend();
await waitFor(() => expect(fetchMock).toHaveBeenCalled());

const request = fetchMock.mock.calls[0];
const requestBody = request[1]!.body as File;
const headers = new Headers(fetchMock.mock.calls[0][1]!.headers);

expect(requestBody).toBeInstanceOf(File);
expect(requestBody.name).toBe('some-file');
expect(headers.get('Content-Type')).toBe('application/octet-stream');
});

it('allows to send empty value', async () => {
render(<TryItWithPersistence httpOperation={octetStreamOperation} />);

clickSend();
await waitFor(() => expect(fetchMock).toHaveBeenCalled());

const body = fetchMock.mock.calls[0][1]!.body as FormData;
expect(body).toBeUndefined();
});
});

describe('Text Request Body', () => {
describe('is attached', () => {
it('to operation with PATCH method', async () => {
Expand Down
22 changes: 20 additions & 2 deletions packages/elements-core/src/components/TryIt/TryIt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getServersToDisplay, getServerVariables } from '../../utils/http-spec/I
import { extractCodeSamples, RequestSamples } from '../RequestSamples';
import { TryItAuth } from './Auth/Auth';
import { usePersistedSecuritySchemeWithValues } from './Auth/authentication-utils';
import { BinaryBody } from './Body/BinaryBody';
import { FormDataBody } from './Body/FormDataBody';
import { BodyParameterValues, useBodyParameterState } from './Body/request-body-utils';
import { RequestBody } from './Body/RequestBody';
Expand Down Expand Up @@ -134,6 +135,8 @@ export const TryIt: React.FC<TryItProps> = ({
return previousValue;
}, {});

const getBinaryValue = () => bodyParameterValues.file;

React.useEffect(() => {
const currentUrl = chosenServer?.url;

Expand All @@ -154,7 +157,11 @@ export const TryIt: React.FC<TryItProps> = ({
parameterValues: parameterValuesWithDefaults,
serverVariableValues,
httpOperation,
bodyInput: formDataState.isFormDataBody ? getValues() : textRequestBody,
bodyInput: formDataState.isFormDataBody
? getValues()
: formDataState.isBinaryBody
? getBinaryValue()
: textRequestBody,
auth: operationAuthValue,
...(isMockingEnabled && { mockData: getMockData(mockUrl, httpOperation, mockingOptions) }),
chosenServer,
Expand Down Expand Up @@ -198,12 +205,17 @@ export const TryIt: React.FC<TryItProps> = ({
try {
setLoading(true);
const mockData = isMockingEnabled ? getMockData(mockUrl, httpOperation, mockingOptions) : undefined;

const request = await buildFetchRequest({
parameterValues: parameterValuesWithDefaults,
serverVariableValues,
httpOperation,
mediaTypeContent,
bodyInput: formDataState.isFormDataBody ? getValues() : textRequestBody,
bodyInput: formDataState.isFormDataBody
? getValues()
: formDataState.isBinaryBody
? getBinaryValue()
: textRequestBody,
mockData,
auth: operationAuthValue,
chosenServer,
Expand Down Expand Up @@ -278,6 +290,12 @@ export const TryIt: React.FC<TryItProps> = ({
onChangeParameterAllow={setAllowedEmptyValues}
isAllowedEmptyValues={isAllowedEmptyValues}
/>
) : formDataState.isBinaryBody ? (
<BinaryBody
specification={formDataState.bodySpecification}
values={bodyParameterValues}
onChangeValues={setBodyParameterValues}
/>
) : mediaTypeContent ? (
<RequestBody
examples={mediaTypeContent.examples ?? []}
Expand Down
49 changes: 31 additions & 18 deletions packages/elements-core/src/components/TryIt/build-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ interface BuildRequestInput {
mediaTypeContent: IMediaTypeContent | undefined;
parameterValues: Dictionary<string, string>;
serverVariableValues: Dictionary<string, string>;
bodyInput?: BodyParameterValues | string;
bodyInput?: BodyParameterValues | string | File;
mockData?: MockData;
auth?: HttpSecuritySchemeWithValues[];
chosenServer?: IServer | null;
Expand Down Expand Up @@ -163,7 +163,10 @@ export async function buildFetchRequest({
const urlObject = new URL(serverUrl + expandedPath);
urlObject.search = new URLSearchParams(queryParamsWithAuth.map(nameAndValueObjectToPair)).toString();

const body = typeof bodyInput === 'object' ? await createRequestBody(mediaTypeContent, bodyInput) : bodyInput;
const body =
typeof bodyInput === 'object' && !(bodyInput instanceof File)
? await createRequestBody(mediaTypeContent, bodyInput)
: bodyInput;

const acceptedMimeTypes = getAcceptedMimeTypes(httpOperation);
const headers = {
Expand Down Expand Up @@ -290,23 +293,33 @@ export async function buildHarRequest({
if (shouldIncludeBody && typeof bodyInput === 'string') {
postData = { mimeType, text: bodyInput };
}
if (shouldIncludeBody && typeof bodyInput === 'object') {
postData = {
mimeType,
params: Object.entries(bodyInput).map(([name, value]) => {
if (value instanceof File) {
return {
name,
fileName: value.name,
contentType: value.type,
};
}
return {
name,
value,

if (shouldIncludeBody) {
if (typeof bodyInput === 'object') {
if (mimeType === 'application/octet-stream' && bodyInput instanceof File) {
postData = {
mimeType,
text: `@${bodyInput.name}`,
};
}),
};
} else {
postData = {
mimeType,
params: Object.entries(bodyInput).map(([name, value]) => {
if (value instanceof File) {
return {
name,
fileName: value.name,
contentType: value.type,
};
}
return {
name,
value,
};
}),
};
}
}
}

return {
Expand Down