diff --git a/packages/elements-core/src/__fixtures__/operations/x-code-samples.ts b/packages/elements-core/src/__fixtures__/operations/x-code-samples.ts index 72343e70a..18cb62928 100644 --- a/packages/elements-core/src/__fixtures__/operations/x-code-samples.ts +++ b/packages/elements-core/src/__fixtures__/operations/x-code-samples.ts @@ -5,17 +5,19 @@ export const xcodeSamples: IHttpOperation = { iid: 'PUT_codeSamples', method: 'put', path: '/todos/{todoId}', - summary: 'Update Todo with code sample overrides', + summary: 'Update Todo with code samples', extensions: { 'x-codeSamples': [ { lang: 'shell', + lib: 'wget', label: 'wGet', source: 'wget \'https://api.stoplight.io/todos/1\' --header=\'Content-Type: application/json\' --header=\'X-Stoplight-Resolver: true\' --header=\'b-account-id: account-id-default\' --header=\'account-id: account-id-default\' --header=\'message-id: example value\' --header=\'message-id: another example\' --header=\'message-id: something else\' --data-raw \'{"name":"string","completed":true,"id":0,"completed_at":"2021-06-30T14:59:00Z","created_at":"2021-06-30T14:59:00Z","updated_at":"2021-06-30T14:59:00Z","user":{"name":"string","age":0,"type":"STANDARD"}}\'', }, { - lang: 'Shell', + lang: 'shell', + lib: 'curl', source: 'curl \'https://api.stoplight.io/todos/1\' --header=\'Content-Type: application/json\' --header=\'X-Stoplight-Resolver: true\' --header=\'b-account-id: account-id-default\' --header=\'account-id: account-id-default\' --header=\'message-id: example value\' --header=\'message-id: another example\' --header=\'message-id: something else\' --data-raw \'{"name":"string","completed":true,"id":0,"completed_at":"2021-06-30T14:59:00Z","created_at":"2021-06-30T14:59:00Z","updated_at":"2021-06-30T14:59:00Z","user":{"name":"string","age":0,"type":"STANDARD"}}\'', }, diff --git a/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.stories.ts b/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.stories.ts index 535115484..f1bdbe974 100644 --- a/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.stories.ts +++ b/packages/elements-core/src/components/Docs/HttpOperation/HttpOperation.stories.ts @@ -9,7 +9,7 @@ export default meta; export const Story = createHoistedStory({ data: httpOperation, layoutOptions: { compact: 600 } }); -export const StoryWithCodeSampleOverrides = createHoistedStory({ +export const StoryWithCustomCodeSamples = createHoistedStory({ data: xcodeSamples, layoutOptions: { compact: 600 }, }); diff --git a/packages/elements-core/src/components/RequestSamples/RequestSamples.stories.tsx b/packages/elements-core/src/components/RequestSamples/RequestSamples.stories.tsx index 780787823..64c878d86 100644 --- a/packages/elements-core/src/components/RequestSamples/RequestSamples.stories.tsx +++ b/packages/elements-core/src/components/RequestSamples/RequestSamples.stories.tsx @@ -26,20 +26,32 @@ HoistedStory.args = { }; HoistedStory.storyName = 'RequestSamples'; -export const RequestSampleWithOverrides = Template.bind({}); +export const RequestSampleWithCustomCodes = Template.bind({}); -RequestSampleWithOverrides.args = { - codeSampleOverrides: [ +RequestSampleWithCustomCodes.args = { + customCodeSamples: [ { lang: 'shell', label: 'cURL', + lib: 'curl', source: 'echo "Hello, World from cURL!"', }, { lang: 'shell', label: 'Wget', + lib: 'wget', source: 'echo "Hello, World from Wget!"', }, + { + lang: 'go', + label: 'Go', + source: 'Some go sample', + }, + { + lang: 'rust', + label: 'Rust', + source: 'Hello rustations!', + }, ], request: { url: 'https://google.com', @@ -52,4 +64,4 @@ RequestSampleWithOverrides.args = { queryString: [], }, }; -RequestSampleWithOverrides.storyName = 'RequestSampleWithOverrides'; +RequestSampleWithCustomCodes.storyName = 'RequestSampleWithCustomCodes'; diff --git a/packages/elements-core/src/components/RequestSamples/RequestSamples.tsx b/packages/elements-core/src/components/RequestSamples/RequestSamples.tsx index 4d260a92e..b4bf6deab 100644 --- a/packages/elements-core/src/components/RequestSamples/RequestSamples.tsx +++ b/packages/elements-core/src/components/RequestSamples/RequestSamples.tsx @@ -1,13 +1,21 @@ import { Box, Button, CopyButton, Menu, MenuItems, Panel } from '@stoplight/mosaic'; import { CodeViewer } from '@stoplight/mosaic-code-viewer'; +import { Dictionary } from '@stoplight/types'; import { Request } from 'har-format'; import { atom, useAtom } from 'jotai'; -import React, { useMemo } from 'react'; +import { cloneDeep, find, findKey } from 'lodash'; +import React, { memo, useEffect, useMemo, useState } from 'react'; import { persistAtom } from '../../utils/jotai/persistAtom'; import { convertRequestToSample } from './convertRequestToSample'; -import { CodeSampleOverride } from './extractCodeSamplesOverrides'; -import { getConfigFor, requestSampleConfigs } from './requestSampleConfigs'; +import { CodeSample } from './extractCodeSamples'; +import { + LanguageConfig, + LibraryConfig, + requestSampleConfigs, + SupportedLanguage, + SupportedLibrary, +} from './requestSampleConfigs'; export interface RequestSamplesProps { /** @@ -17,15 +25,27 @@ export interface RequestSamplesProps { /** * The list of code examples to override the generated ones. */ - codeSampleOverrides?: CodeSampleOverride[]; + customCodeSamples?: CodeSample[]; /** * True when embedded in TryIt */ embeddedInMd?: boolean; } -const selectedLanguageAtom = persistAtom('RequestSamples_selectedLanguage', atom('Shell')); -const selectedLibraryAtom = persistAtom('RequestSamples_selectedLibrary', atom('cURL')); +type SampleCode = { + displayText: string; + sampleCode?: string; +}; + +type LibraryConfigWithCode = LibraryConfig & SampleCode; + +type LanguageConfigWithCode = LanguageConfig & + SampleCode & { + libraries: Dictionary; + }; + +const selectedLanguageAtom = persistAtom('RequestSamples_selectedLanguage', atom('shell')); +const selectedLibraryAtom = persistAtom('RequestSamples_selectedLibrary', atom('curl')); const fallbackText = 'Unable to generate code example'; @@ -34,41 +54,136 @@ const fallbackText = 'Unable to generate code example'; * * The programming language can be selected by the user and is remembered across instances and remounts. */ -export const RequestSamples = React.memo( - ({ request, embeddedInMd = false, codeSampleOverrides = [] }) => { - const [selectedLanguage, setSelectedLanguage] = useAtom(selectedLanguageAtom); - const [selectedLibrary, setSelectedLibrary] = useAtom(selectedLibraryAtom); - - const { httpSnippetLanguage, httpSnippetLibrary, mosaicCodeViewerLanguage } = getConfigFor( - selectedLanguage, - selectedLibrary, - ); - - const [requestSample, setRequestSample] = React.useState(null); - React.useEffect(() => { - let isStale = false; - let selectedCodeSampleOverride: string | undefined; - - if (codeSampleOverrides.length > 0) { - const codeSampleOverride = codeSampleOverrides.find(override => { - if (override.label) { - return ( - override.lang.toLowerCase() === httpSnippetLanguage && override.label.toLowerCase() === httpSnippetLibrary - ); +export const RequestSamples = memo(({ request, embeddedInMd = false, customCodeSamples = [] }) => { + const [selectedLanguage, setSelectedLanguage] = useAtom(selectedLanguageAtom); + const [selectedLibrary, setSelectedLibrary] = useAtom(selectedLibraryAtom); + + // combines the predefined samples with the custom ones + const allRequestSamples = useMemo(() => { + const requestSamples = cloneDeep(requestSampleConfigs as Dictionary); + + Object.entries(requestSamples).forEach(([languageKey, value]) => { + value.displayText = languageKey; + + Object.entries((value.libraries ??= {})).forEach(([libKey, value]) => { + value.displayText = `${languageKey} / ${libKey}`; + }); + }); + + for (const customCodeSample of customCodeSamples) { + const existingLanguageSampleKey = findKey(requestSamples, { + httpSnippetLanguage: customCodeSample.lang.toLowerCase(), + }); + const existingLanguageSample = requestSamples[existingLanguageSampleKey!]; + + if (!existingLanguageSample) { + const newLanguageSample: LanguageConfigWithCode = { + displayText: customCodeSample.lang, + mosaicCodeViewerLanguage: customCodeSample.lang as any, // TODO: no type guards to prevent this + httpSnippetLanguage: customCodeSample.lang, + libraries: {}, + }; + + if (customCodeSample.lib) { + newLanguageSample.libraries[customCodeSample.lib] = { + displayText: `${customCodeSample.lang} / ${customCodeSample.lib}`, + httpSnippetLibrary: customCodeSample.lib, + sampleCode: customCodeSample.source, + }; + } else { + newLanguageSample.sampleCode = customCodeSample.source; + } + + requestSamples[customCodeSample.label] = newLanguageSample; + } else { + existingLanguageSample.libraries ??= {}; + + if (customCodeSample.lib) { + const existingLibrarySampleKey = findKey(existingLanguageSample.libraries, { + httpSnippetLibrary: customCodeSample.lib, + }); + const existingLibrarySample = existingLanguageSample.libraries[existingLibrarySampleKey!]; + + if (!existingLibrarySample) { + const newLibrarySample: LibraryConfigWithCode = { + displayText: `${existingLanguageSample} / ${customCodeSample.lib}`, + httpSnippetLibrary: customCodeSample.lib, + sampleCode: customCodeSample.source, + }; + + existingLanguageSample.libraries[customCodeSample.lib] = newLibrarySample; + } else { + existingLibrarySample.displayText = `${existingLanguageSampleKey} / ${existingLibrarySampleKey}`; + existingLibrarySample.sampleCode = customCodeSample.source; } - return override.lang.toLowerCase() === httpSnippetLanguage; - }); - if (codeSampleOverride) { - selectedCodeSampleOverride = codeSampleOverride.source; + } else { + existingLanguageSample.sampleCode = customCodeSample.source; } } + } - if (selectedCodeSampleOverride) { - if (!isStale) { - setRequestSample(selectedCodeSampleOverride); - } + return requestSamples; + }, [customCodeSamples]); + + // Computes the menu items and gets the selected config + const [menuItems, selectedSampleConfig] = useMemo(() => { + const items: MenuItems = Object.entries(allRequestSamples).map(([languageLabel, languageConfig]) => { + const hasLibraries = Object.keys(languageConfig.libraries ?? {}).length > 0; + return { + id: languageLabel, + title: languageLabel, + isChecked: selectedLanguage === languageConfig.httpSnippetLanguage, + closeOnPress: !hasLibraries, + onPress: hasLibraries + ? undefined + : () => { + setSelectedLanguage(languageConfig.httpSnippetLanguage); + setSelectedLibrary(''); + }, + children: hasLibraries + ? Object.entries(languageConfig.libraries).map(([libraryLabel, libraryConfig]) => ({ + id: `${languageLabel}-${libraryLabel}`, + title: libraryLabel, + isChecked: + selectedLanguage === languageConfig.httpSnippetLanguage && + selectedLibrary === libraryConfig.httpSnippetLibrary, + onPress: () => { + setSelectedLanguage(languageConfig.httpSnippetLanguage); + setSelectedLibrary(libraryConfig.httpSnippetLibrary); + }, + })) + : undefined, + }; + }); + + const selectedLanguageSample = find(allRequestSamples, { httpSnippetLanguage: selectedLanguage }); + const selectedLibrarySample = find(selectedLanguageSample?.libraries ?? {}, { + httpSnippetLibrary: selectedLibrary, + }); + + return [ + items, + { + ...selectedLibrarySample, + ...selectedLanguageSample, + displayText: selectedLibrarySample?.displayText ?? selectedLanguageSample?.displayText, + }, + ]; + }, [allRequestSamples, selectedLanguage, selectedLibrary, setSelectedLanguage, setSelectedLibrary]); + + const [requestSample, setRequestSample] = useState(null); + useEffect(() => { + let isStale = false; + + if (selectedSampleConfig) { + if (selectedSampleConfig.sampleCode) { + setRequestSample(selectedSampleConfig.sampleCode); } else { - convertRequestToSample(httpSnippetLanguage, httpSnippetLibrary, request) + convertRequestToSample( + selectedSampleConfig.httpSnippetLanguage!, + selectedSampleConfig.httpSnippetLibrary!, + request, + ) .then(example => { if (!isStale) { setRequestSample(example); @@ -80,80 +195,52 @@ export const RequestSamples = React.memo( } }); } + } else { + setRequestSample(fallbackText); + } - return () => { - isStale = true; - }; - }, [request, httpSnippetLanguage, httpSnippetLibrary, codeSampleOverrides]); - - const menuItems = useMemo(() => { - const items: MenuItems = Object.entries(requestSampleConfigs).map(([language, config]) => { - const hasLibraries = config.libraries && Object.keys(config.libraries).length > 0; - return { - id: language, - title: language, - isChecked: selectedLanguage === language, - onPress: hasLibraries - ? undefined - : () => { - setSelectedLanguage(language); - setSelectedLibrary(''); - }, - children: config.libraries - ? Object.keys(config.libraries).map(library => ({ - id: `${language}-${library}`, - title: library, - isChecked: selectedLanguage === language && selectedLibrary === library, - onPress: () => { - setSelectedLanguage(language); - setSelectedLibrary(library); - }, - })) - : undefined, - }; - }); + return () => { + isStale = true; + }; + }, [request, selectedSampleConfig]); - return items; - }, [selectedLanguage, selectedLibrary, setSelectedLanguage, setSelectedLibrary]); - - return ( - - }> - - ( - - )} - /> - - - - - {requestSample !== null && ( - - )} - - - ); - }, -); + return ( + + }> + + ( + + )} + /> + + + + + {requestSample !== null && ( + + )} + + + ); +}); diff --git a/packages/elements-core/src/components/RequestSamples/extractCodeSamplesOverrides.ts b/packages/elements-core/src/components/RequestSamples/extractCodeSamples.ts similarity index 55% rename from packages/elements-core/src/components/RequestSamples/extractCodeSamplesOverrides.ts rename to packages/elements-core/src/components/RequestSamples/extractCodeSamples.ts index 2edde6bc2..aa768da0a 100644 --- a/packages/elements-core/src/components/RequestSamples/extractCodeSamplesOverrides.ts +++ b/packages/elements-core/src/components/RequestSamples/extractCodeSamples.ts @@ -1,42 +1,47 @@ import { isPlainObject } from '@stoplight/json'; import { isString } from 'lodash'; -import { isHttpOperation } from '../../utils/guards'; - // Based on Redocly // https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/ -export type CodeSampleOverride = { +export type CodeSample = { /** * Code sample language. */ lang: string; /** - * Code sample label, for example Node or Python2.7, optional, lang is used by default + * Code sample library, optional. + */ + lib?: string; + /** + * Code sample label, for example Node or Python2.7, optional, lib or lang is used by default */ - label?: string; + label: string; /** * Code sample source code */ source: string; }; -export const extractCodeSampleOverrides = (obj: unknown): CodeSampleOverride[] => { - if (!isHttpOperation(obj)) { +export const extractCodeSamples = (obj: unknown): CodeSample[] => { + if (!isPlainObject(obj) || !isPlainObject(obj.extensions)) { return []; } - const codeSamples = obj.extensions?.['x-codeSamples']; + const codeSamples = obj.extensions['x-codeSamples']; if (!Array.isArray(codeSamples)) { return []; } return codeSamples.reduce((extracted, item) => { if (isPlainObject(item) && isString(item['lang']) && isString(item['source'])) { + const lib = isString(item['lib']) ? item['lib'] : undefined; + const label = isString(item['label']) ? item['label'] : lib ?? item['lang']; extracted.push({ lang: item['lang'], - label: isString(item['label']) ? item['label'] : undefined, // TODO: does not support $ref objects - source: item['source'], + lib, + label, + source: item['source'], // TODO: does not support $ref objects }); } diff --git a/packages/elements-core/src/components/RequestSamples/index.ts b/packages/elements-core/src/components/RequestSamples/index.ts index 032a71d8c..288fbce37 100644 --- a/packages/elements-core/src/components/RequestSamples/index.ts +++ b/packages/elements-core/src/components/RequestSamples/index.ts @@ -1,2 +1,2 @@ -export * from './extractCodeSamplesOverrides'; +export * from './extractCodeSamples'; export * from './RequestSamples'; diff --git a/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts b/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts index 8969538aa..5b36c64a0 100644 --- a/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts +++ b/packages/elements-core/src/components/RequestSamples/requestSampleConfigs.ts @@ -1,17 +1,17 @@ import { CodeViewerLanguage } from '@stoplight/mosaic-code-viewer'; import { Dictionary } from '@stoplight/types'; -type SupportedLanguage = string; -type SupportedLibrary = string; -interface LibraryConfig { +export type SupportedLanguage = string; +export type SupportedLibrary = string; +export interface LibraryConfig { httpSnippetLibrary: string; } -interface LanguageConfig { +export interface LanguageConfig { mosaicCodeViewerLanguage: CodeViewerLanguage; httpSnippetLanguage: string; libraries?: Dictionary; } -type RequestSampleConfigs = Dictionary; +export type RequestSampleConfigs = Dictionary; export const requestSampleConfigs: RequestSampleConfigs = { Shell: { @@ -180,10 +180,3 @@ export const requestSampleConfigs: RequestSampleConfigs = { httpSnippetLanguage: 'swift', }, }; - -export const getConfigFor = (language: string, library: string): LanguageConfig & Partial => { - const languageConfig = requestSampleConfigs[language]; - const libraryConfig = languageConfig.libraries?.[library] || {}; - - return { ...languageConfig, ...libraryConfig }; -}; diff --git a/packages/elements-core/src/components/TryIt/TryIt.tsx b/packages/elements-core/src/components/TryIt/TryIt.tsx index e817f8dc2..b10d799bd 100644 --- a/packages/elements-core/src/components/TryIt/TryIt.tsx +++ b/packages/elements-core/src/components/TryIt/TryIt.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { HttpMethodColors } from '../../constants'; import { isHttpOperation, isHttpWebhookOperation } from '../../utils/guards'; import { getServersToDisplay, getServerVariables } from '../../utils/http-spec/IServer'; -import { extractCodeSampleOverrides, RequestSamples } from '../RequestSamples'; +import { extractCodeSamples, RequestSamples } from '../RequestSamples'; import { TryItAuth } from './Auth/Auth'; import { usePersistedSecuritySchemeWithValues } from './Auth/authentication-utils'; import { FormDataBody } from './Body/FormDataBody'; @@ -115,7 +115,7 @@ export const TryIt: React.FC = ({ parameter => parameter.required && !parameterValuesWithDefaults[parameter.name], ); - const codeSampleOverrides = extractCodeSampleOverrides(httpOperation); + const customCodeSamples = extractCodeSamples(httpOperation); const getValues = () => Object.keys(bodyParameterValues) @@ -343,7 +343,7 @@ export const TryIt: React.FC = ({ {tryItPanelElem} {requestData && embeddedInMd && ( - + )} {response && !('error' in response) && } {response && 'error' in response && } diff --git a/packages/elements-core/src/components/TryIt/TryItWithRequestSamples.tsx b/packages/elements-core/src/components/TryIt/TryItWithRequestSamples.tsx index 2338d4df4..51a50301a 100644 --- a/packages/elements-core/src/components/TryIt/TryItWithRequestSamples.tsx +++ b/packages/elements-core/src/components/TryIt/TryItWithRequestSamples.tsx @@ -2,7 +2,7 @@ import { Box, InvertTheme, VStack } from '@stoplight/mosaic'; import { Request as HarRequest } from 'har-format'; import * as React from 'react'; -import { extractCodeSampleOverrides, RequestSamples } from '../RequestSamples'; +import { extractCodeSamples, RequestSamples } from '../RequestSamples'; import { ResponseExamples, ResponseExamplesProps } from '../ResponseExamples/ResponseExamples'; import { TryIt, TryItProps } from './TryIt'; @@ -12,7 +12,7 @@ export type TryItWithRequestSamplesProps = Omit & export const TryItWithRequestSamples: React.FC = ({ hideTryIt, ...props }) => { const [requestData, setRequestData] = React.useState(); - const codeSampleOverrides = extractCodeSampleOverrides(props.httpOperation); + const customCodeSamples = extractCodeSamples(props.httpOperation); return ( @@ -24,7 +24,7 @@ export const TryItWithRequestSamples: React.FC = ( )} - {requestData && } + {requestData && }