Skip to content

Commit

Permalink
feat: File Uploads as Request Body (#2737)
Browse files Browse the repository at this point in the history
* feat: Add support for application octet-stream

* feat: Improve types and add unit tests

* feat: fix lint errors and typo

* feat: fix on change file value

* feat: remove regex in convert request to sample

* feat: bump version
  • Loading branch information
lukasikp authored Nov 26, 2024
1 parent e4131eb commit f01e454
Show file tree
Hide file tree
Showing 10 changed files with 240 additions and 29 deletions.
2 changes: 1 addition & 1 deletion packages/elements-core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stoplight/elements-core",
"version": "8.4.7",
"version": "8.5.0",
"sideEffects": [
"web-components.min.js",
"src/web-components/**",
Expand Down
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
4 changes: 2 additions & 2 deletions packages/elements-dev-portal/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@stoplight/elements-dev-portal",
"version": "2.4.9",
"version": "2.5.0",
"description": "UI components for composing beautiful developer documentation.",
"keywords": [],
"sideEffects": [
Expand Down Expand Up @@ -66,7 +66,7 @@
"dependencies": {
"@stoplight/markdown-viewer": "^5.7.1",
"@stoplight/mosaic": "^1.53.4",
"@stoplight/elements-core": "^8.4.7",
"@stoplight/elements-core": "^8.5.0",
"@stoplight/path": "^1.3.2",
"@stoplight/types": "^14.0.0",
"classnames": "^2.2.6",
Expand Down
Loading

0 comments on commit f01e454

Please sign in to comment.