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 5 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;
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`convertRequestToSample converts an application/octet-stream request to a sample 1`] = `
"curl --request POST \\\\
--url https://todos.stoplight.io/todos/todoId \\\\
--header 'Content-Type: application/octet-stream' \\\\
--data-binary 'binary data'"
`;

exports[`given c, convertRequestToSample converts a request to a sample 1`] = `
"CURL *hnd = curl_easy_init();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,27 @@ test.each(languages)('given %s, convertRequestToSample converts a request to a s

expect(convertRequestToSample(language, library, har)).resolves.toMatchSnapshot();
});

test('convertRequestToSample converts an application/octet-stream request to a sample', async () => {
const har = {
method: 'POST',
url: 'https://todos.stoplight.io/todos/todoId',
httpVersion: 'HTTP/1.1',
cookies: [],
headers: [
{
name: 'Content-Type',
value: 'application/octet-stream',
},
],
queryString: [],
postData: {
mimeType: 'application/octet-stream',
text: 'binary data',
},
headersSize: -1,
bodySize: -1,
};

await expect(convertRequestToSample('shell', 'curl', har)).resolves.toMatchSnapshot();
});
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const convertRequestToSample = async (
}
if (typeof converted === 'string') {
converted = converted.replace(/%7B/g, '{').replace(/%7D/g, '}');
if (request.postData?.mimeType === 'application/octet-stream') {
converted = converted.replace('--data', '--data-binary');
}
}

return converted;
Expand Down
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
Loading