diff --git a/packages/elements-core/package.json b/packages/elements-core/package.json index b6493da44..f63b046c2 100644 --- a/packages/elements-core/package.json +++ b/packages/elements-core/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/elements-core", - "version": "8.4.7", + "version": "8.5.0", "sideEffects": [ "web-components.min.js", "src/web-components/**", diff --git a/packages/elements-core/src/__fixtures__/operations/application-octet-stream-post.ts b/packages/elements-core/src/__fixtures__/operations/application-octet-stream-post.ts new file mode 100644 index 000000000..8d3de2126 --- /dev/null +++ b/packages/elements-core/src/__fixtures__/operations/application-octet-stream-post.ts @@ -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; diff --git a/packages/elements-core/src/components/TryIt/Body/BinaryBody.tsx b/packages/elements-core/src/components/TryIt/Body/BinaryBody.tsx new file mode 100644 index 000000000..8aefb84e1 --- /dev/null +++ b/packages/elements-core/src/components/TryIt/Body/BinaryBody.tsx @@ -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 = ({ 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 ( + + } + > + Body + + + { + newValue ? onChangeValues({ file: newValue }) : onChangeValues({}); + }} + /> + + + ); +}; diff --git a/packages/elements-core/src/components/TryIt/Body/__tests__/BinaryBody.test.tsx b/packages/elements-core/src/components/TryIt/Body/__tests__/BinaryBody.test.tsx new file mode 100644 index 000000000..0e8aa7f97 --- /dev/null +++ b/packages/elements-core/src/components/TryIt/Body/__tests__/BinaryBody.test.tsx @@ -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(); + + expect(getByLabelText('file')).toBeInTheDocument(); + }); +}); diff --git a/packages/elements-core/src/components/TryIt/Body/request-body-utils.ts b/packages/elements-core/src/components/TryIt/Body/request-body-utils.ts index d728aad0b..f1d6748de 100644 --- a/packages/elements-core/src/components/TryIt/Body/request-body-utils.ts +++ b/packages/elements-core/src/components/TryIt/Body/request-body-utils.ts @@ -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, @@ -74,16 +80,17 @@ const requestBodyCreators: Record = { 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(initialState); const [isAllowedEmptyValue, setAllowedEmptyValue] = React.useState({}); @@ -98,7 +105,15 @@ 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 [ @@ -106,7 +121,7 @@ export const useBodyParameterState = (mediaTypeContent: IMediaTypeContent | unde setBodyParameterValues, isAllowedEmptyValue, setAllowedEmptyValue, - { isFormDataBody: false, bodySpecification: undefined }, + { isFormDataBody: false, isBinaryBody: false, bodySpecification: undefined }, ] as const; } }; diff --git a/packages/elements-core/src/components/TryIt/TryIt.spec.tsx b/packages/elements-core/src/components/TryIt/TryIt.spec.tsx index 162d8cc64..372172f5f 100644 --- a/packages/elements-core/src/components/TryIt/TryIt.spec.tsx +++ b/packages/elements-core/src/components/TryIt/TryIt.spec.tsx @@ -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'; @@ -533,6 +534,50 @@ describe('TryIt', () => { }); }); + describe('Binary body', () => { + it('shows panel when there is file input', () => { + render(); + + let parametersHeader = screen.queryByText('Body'); + expect(parametersHeader).toBeInTheDocument(); + }); + + it('displays file input correctly', () => { + render(); + + 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(); + + 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(); + + 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 () => { diff --git a/packages/elements-core/src/components/TryIt/TryIt.tsx b/packages/elements-core/src/components/TryIt/TryIt.tsx index 1983e228d..56e8595e5 100644 --- a/packages/elements-core/src/components/TryIt/TryIt.tsx +++ b/packages/elements-core/src/components/TryIt/TryIt.tsx @@ -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'; @@ -134,6 +135,8 @@ export const TryIt: React.FC = ({ return previousValue; }, {}); + const getBinaryValue = () => bodyParameterValues.file; + React.useEffect(() => { const currentUrl = chosenServer?.url; @@ -154,7 +157,11 @@ export const TryIt: React.FC = ({ 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, @@ -198,12 +205,17 @@ export const TryIt: React.FC = ({ 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, @@ -278,6 +290,12 @@ export const TryIt: React.FC = ({ onChangeParameterAllow={setAllowedEmptyValues} isAllowedEmptyValues={isAllowedEmptyValues} /> + ) : formDataState.isBinaryBody ? ( + ) : mediaTypeContent ? ( ; serverVariableValues: Dictionary; - bodyInput?: BodyParameterValues | string; + bodyInput?: BodyParameterValues | string | File; mockData?: MockData; auth?: HttpSecuritySchemeWithValues[]; chosenServer?: IServer | null; @@ -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 = { @@ -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 { diff --git a/packages/elements-dev-portal/package.json b/packages/elements-dev-portal/package.json index 760441ab1..f0d93b8dd 100644 --- a/packages/elements-dev-portal/package.json +++ b/packages/elements-dev-portal/package.json @@ -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": [ @@ -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", diff --git a/packages/elements/package.json b/packages/elements/package.json index a1c82140d..94ecfad8c 100644 --- a/packages/elements/package.json +++ b/packages/elements/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/elements", - "version": "8.4.7", + "version": "8.5.0", "description": "UI components for composing beautiful developer documentation.", "keywords": [], "sideEffects": [ @@ -63,7 +63,7 @@ ] }, "dependencies": { - "@stoplight/elements-core": "^8.4.7", + "@stoplight/elements-core": "^8.5.0", "@stoplight/http-spec": "^7.1.0", "@stoplight/json": "^3.18.1", "@stoplight/mosaic": "^1.53.4",