From 16e8a46781b076283c2e9dc33ae8ca77b9dcffec Mon Sep 17 00:00:00 2001 From: mathis-m Date: Fri, 5 Feb 2021 01:46:12 +0100 Subject: [PATCH] feat: make curlify a snippets plugin Signed-off-by: mathis-m --- src/core/components/curl.jsx | 45 ---- src/core/components/live-response.jsx | 5 +- src/core/curlify.js | 75 ------ src/core/index.js | 25 +- src/core/plugins/request-snippets/fn.js | 217 ++++++++++++++++++ src/core/plugins/request-snippets/index.js | 16 ++ .../request-snippets/request-snippets.jsx | 127 ++++++++++ .../plugins/request-snippets/selectors.js | 45 ++++ src/core/presets/base.js | 6 +- src/core/syntax-highlighting.js | 4 + 10 files changed, 438 insertions(+), 127 deletions(-) delete mode 100644 src/core/components/curl.jsx delete mode 100644 src/core/curlify.js create mode 100644 src/core/plugins/request-snippets/fn.js create mode 100644 src/core/plugins/request-snippets/index.js create mode 100644 src/core/plugins/request-snippets/request-snippets.jsx create mode 100644 src/core/plugins/request-snippets/selectors.js diff --git a/src/core/components/curl.jsx b/src/core/components/curl.jsx deleted file mode 100644 index c453e6a0681..00000000000 --- a/src/core/components/curl.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react" -import PropTypes from "prop-types" -import curlify from "core/curlify" -import { CopyToClipboard } from "react-copy-to-clipboard" -import {SyntaxHighlighter, getStyle} from "core/syntax-highlighting" -import get from "lodash/get" - -export default class Curl extends React.Component { - static propTypes = { - getConfigs: PropTypes.func.isRequired, - request: PropTypes.object.isRequired - } - - render() { - let { request, getConfigs } = this.props - let curl = curlify(request) - - const config = getConfigs() - - const curlBlock = get(config, "syntaxHighlight.activated") - ? - {curl} - - : - - - return ( -
-

Curl

-
-
-
- {curlBlock} -
-
- ) - } - -} diff --git a/src/core/components/live-response.jsx b/src/core/components/live-response.jsx index 4bb9d8cda05..53598f80e4e 100644 --- a/src/core/components/live-response.jsx +++ b/src/core/components/live-response.jsx @@ -62,7 +62,6 @@ export default class LiveResponse extends React.Component { const headersKeys = Object.keys(headers) const contentType = headers["content-type"] || headers["Content-Type"] - const Curl = getComponent("curl") const ResponseBody = getComponent("responseBody") const returnObject = headersKeys.map(key => { var joinedHeaders = Array.isArray(headers[key]) ? headers[key].join() : headers[key] @@ -70,10 +69,10 @@ export default class LiveResponse extends React.Component { }) const hasHeaders = returnObject.length !== 0 const Markdown = getComponent("Markdown", true) - + const RequestSnippets = getComponent("RequestSnippets", true) return (
- { curlRequest && } + { curlRequest && } { url &&

Request URL

diff --git a/src/core/curlify.js b/src/core/curlify.js deleted file mode 100644 index 0735fdccd07..00000000000 --- a/src/core/curlify.js +++ /dev/null @@ -1,75 +0,0 @@ -import win from "./window" -import { Map } from "immutable" - -/** - * if duplicate key name existed from FormData entries, - * we mutated the key name by appending a hashIdx - * @param {String} k - possibly mutated key name - * @return {String} - src key name - */ -const extractKey = (k) => { - const hashIdx = "_**[]" - if (k.indexOf(hashIdx) < 0) { - return k - } - return k.split(hashIdx)[0].trim() -} - -export default function curl( request ){ - let curlified = [] - let isMultipartFormDataRequest = false - let headers = request.get("headers") - curlified.push( "curl" ) - - if (request.get("curlOptions")) { - curlified.push(...request.get("curlOptions")) - } - - curlified.push( "-X", request.get("method") ) - curlified.push( `"${request.get("url")}"`) - - if ( headers && headers.size ) { - for( let p of request.get("headers").entries() ){ - let [ h,v ] = p - curlified.push( "-H " ) - curlified.push( `"${h}: ${v.replace(/\$/g, "\\$")}"` ) - isMultipartFormDataRequest = isMultipartFormDataRequest || /^content-type$/i.test(h) && /^multipart\/form-data$/i.test(v) - } - } - - if ( request.get("body") ){ - if (isMultipartFormDataRequest && ["POST", "PUT", "PATCH"].includes(request.get("method"))) { - for( let [ k,v ] of request.get("body").entrySeq()) { - let extractedKey = extractKey(k) - curlified.push( "-F" ) - if (v instanceof win.File) { - curlified.push(`"${extractedKey}=@${v.name}${v.type ? `;type=${v.type}` : ""}"` ) - } else { - curlified.push(`"${extractedKey}=${v}"` ) - } - } - } else { - curlified.push( "-d" ) - let reqBody = request.get("body") - if (!Map.isMap(reqBody)) { - curlified.push( JSON.stringify( request.get("body") ).replace(/\\n/g, "").replace(/\$/g, "\\$") ) - } else { - let curlifyToJoin = [] - for (let [k, v] of request.get("body").entrySeq()) { - let extractedKey = extractKey(k) - if (v instanceof win.File) { - curlifyToJoin.push(`"${extractedKey}":{"name":"${v.name}"${v.type ? `,"type":"${v.type}"` : ""}}`) - } else { - curlifyToJoin.push(`"${extractedKey}":${JSON.stringify(v).replace(/\\n/g, "").replace("$", "\\$")}`) - } - } - curlified.push(`{${curlifyToJoin.join()}}`) - } - } - } else if(!request.get("body") && request.get("method") === "POST") { - curlified.push( "-d" ) - curlified.push( "\"\"" ) - } - - return curlified.join( " " ) -} diff --git a/src/core/index.js b/src/core/index.js index 72a32d3ff03..50e5efe0ebb 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -53,6 +53,28 @@ export default function SwaggerUI(opts) { showExtensions: false, showCommonExtensions: false, withCredentials: undefined, + requestSnippets: { + generators: { + "curl_bash": { + title: "cURL (bash)", + syntax: "bash" + }, + "curl_powershell": { + title: "cURL (PowerShell)", + syntax: "powershell" + }, + "curl_cmd": { + title: "cURL (CMD)", + syntax: "bash" + }, + "node_native": { + title: "Node.js (Native)", + syntax: "javascript" + }, + }, + defaultExpanded: true, + languagesMask: null, // e.g. only show curl bash = ["curl_bash"] + }, supportedSubmitMethods: [ "get", "put", @@ -107,7 +129,8 @@ export default function SwaggerUI(opts) { spec: { spec: "", url: constructorConfig.url - } + }, + requestSnippets: constructorConfig.requestSnippets }, constructorConfig.initialState) } diff --git a/src/core/plugins/request-snippets/fn.js b/src/core/plugins/request-snippets/fn.js new file mode 100644 index 00000000000..f692d2d37b3 --- /dev/null +++ b/src/core/plugins/request-snippets/fn.js @@ -0,0 +1,217 @@ +import win from "../../window" +import { Map } from "immutable" +import Url from "url-parse" + +/** + * if duplicate key name existed from FormData entries, + * we mutated the key name by appending a hashIdx + * @param {String} k - possibly mutated key name + * @return {String} - src key name + */ +const extractKey = (k) => { + const hashIdx = "_**[]" + if (k.indexOf(hashIdx) < 0) { + return k + } + return k.split(hashIdx)[0].trim() +} + +const escapeShell = (str) => { + if (str === "-d ") { + return str + } + // eslint-disable-next-line no-useless-escape + if (!/^[_\/-]/g.test(str)) + return ("'" + str + .replace(/'/g, "'\\''") + "'") + else + return str +} + +const escapeCMD = (str) => { + str = str + .replace(/\^/g, "^^") + .replace(/\\"/g, "\\\\\"") + .replace(/"/g, "\"\"") + .replace(/\n/g, "^\n") + if (str === "-d ") { + return str + .replace(/-d /g, "-d ^\n") + } + // eslint-disable-next-line no-useless-escape + if (!/^[_\/-]/g.test(str)) + return "\"" + str + "\"" + else + return str +} + +const escapePowershell = (str) => { + if (str === "-d ") { + return str + } + if (/\n/.test(str)) { + return "@\"\n" + str.replace(/"/g, "\\\"").replace(/`/g, "``").replace(/\$/, "`$") + "\n\"@" + } + // eslint-disable-next-line no-useless-escape + if (!/^[_\/-]/g.test(str)) + return "'" + str + .replace(/"/g, "\"\"") + .replace(/'/g, "''") + "'" + else + return str +} + +function getStringBodyOfMap(request) { + let curlifyToJoin = [] + for (let [k, v] of request.get("body").entrySeq()) { + let extractedKey = extractKey(k) + if (v instanceof win.File) { + curlifyToJoin.push(` "${extractedKey}": {\n "name": "${v.name}"${v.type ? `,\n "type": "${v.type}"` : ""}\n }`) + } else { + curlifyToJoin.push(` "${extractedKey}": ${JSON.stringify(v, null, 2).replace(/(\r\n|\r|\n)/g, "\n ")}`) + } + } + return `{\n${curlifyToJoin.join(",\n")}\n}` +} + +const curlify = (request, escape, newLine, ext = "") => { + let isMultipartFormDataRequest = false + let curlified = "" + const addWords = (...args) => curlified += " " + args.map(escape).join(" ") + const addWordsWithoutLeadingSpace = (...args) => curlified += args.map(escape).join(" ") + const addNewLine = () => curlified += ` ${newLine}` + const addIndent = (level = 1) => curlified += " ".repeat(level) + let headers = request.get("headers") + curlified += "curl" + ext + + if (request.has("curlOptions")) { + addWords(...request.get("curlOptions")) + } + + addWords("-X", request.get("method")) + + addNewLine() + addIndent() + addWordsWithoutLeadingSpace(`${request.get("url")}`) + + if (headers && headers.size) { + for (let p of request.get("headers").entries()) { + addNewLine() + addIndent() + let [h, v] = p + addWordsWithoutLeadingSpace("-H", `${h}: ${v}`) + isMultipartFormDataRequest = isMultipartFormDataRequest || /^content-type$/i.test(h) && /^multipart\/form-data$/i.test(v) + } + } + + if (request.get("body")) { + if (isMultipartFormDataRequest && ["POST", "PUT", "PATCH"].includes(request.get("method"))) { + for (let [k, v] of request.get("body").entrySeq()) { + let extractedKey = extractKey(k) + addNewLine() + addIndent() + addWordsWithoutLeadingSpace("-F") + if (v instanceof win.File) { + addWords(`${extractedKey}=@${v.name}${v.type ? `;type=${v.type}` : ""}`) + } else { + addWords(`${extractedKey}=${v}`) + } + } + } else { + addNewLine() + addIndent() + addWordsWithoutLeadingSpace("-d ") + let reqBody = request.get("body") + if (!Map.isMap(reqBody)) { + if (typeof reqBody !== "string") { + reqBody = JSON.stringify(reqBody) + } + addWordsWithoutLeadingSpace(reqBody) + } else { + addWordsWithoutLeadingSpace(getStringBodyOfMap(request)) + } + } + } else if (!request.get("body") && request.get("method") === "POST") { + addNewLine() + addIndent() + addWordsWithoutLeadingSpace("-d ''") + } + + return curlified +} + +// eslint-disable-next-line camelcase +export const requestSnippetGenerator_curl_powershell = (request) => { + return curlify(request, escapePowershell, "`\n", ".exe") +} + +// eslint-disable-next-line camelcase +export const requestSnippetGenerator_curl_bash = (request) => { + return curlify(request, escapeShell, "\\\n") +} + +// eslint-disable-next-line camelcase +export const requestSnippetGenerator_curl_cmd = (request) => { + return curlify(request, escapeCMD, "^\n") +} + +// eslint-disable-next-line camelcase +export const requestSnippetGenerator_node_native = (request) => { + const url = new Url(request.get("url")) + let isMultipartFormDataRequest = false + const headers = request.get("headers") + if(headers && headers.size) { + request.get("headers").map((val, key) => { + isMultipartFormDataRequest = isMultipartFormDataRequest || /^content-type$/i.test(key) && /^multipart\/form-data$/i.test(val) + }) + } + const packageStr = url.protocol === "https:" ? "https" : "http" + let reqBody = request.get("body") + if (request.get("body")) { + if (isMultipartFormDataRequest && ["POST", "PUT", "PATCH"].includes(request.get("method"))) { + return "throw new Error(\"Currently unsupported content-type: /^multipart\\/form-data$/i\");" + } else { + if (!Map.isMap(reqBody)) { + if (typeof reqBody !== "string") { + reqBody = JSON.stringify(reqBody) + } + } else { + reqBody = getStringBodyOfMap(request) + } + } + } else if (!request.get("body") && request.get("method") === "POST") { + reqBody = "" + } + + const stringBody = "`" + (reqBody || "") + .replace(/\\n/g, "\n") + .replace(/`/g, "\\`") + + "`" + + return `const http = require("${packageStr}"); + +const options = { + "method": "${request.get("method")}", + "hostname": "${url.host}", + "port": ${url.port || "null"}, + "path": "${url.pathname}"${headers && headers.size ? `, + "headers": { + ${request.get("headers").map((val, key) => `"${key}": "${val}"`).valueSeq().join(",\n ")} + }` : ""} +}; + +const req = http.request(options, function (res) { + const chunks = []; + + res.on("data", function (chunk) { + chunks.push(chunk); + }); + + res.on("end", function () { + const body = Buffer.concat(chunks); + console.log(body.toString()); + }); +}); +${reqBody ? `\nreq.write(${stringBody});` : ""} +req.end();` +} diff --git a/src/core/plugins/request-snippets/index.js b/src/core/plugins/request-snippets/index.js new file mode 100644 index 00000000000..cbda08f996b --- /dev/null +++ b/src/core/plugins/request-snippets/index.js @@ -0,0 +1,16 @@ +import * as fn from "./fn" +import * as selectors from "./selectors" +import { RequestSnippets } from "./request-snippets" +export default () => { + return { + components: { + RequestSnippets + }, + fn, + statePlugins: { + requestSnippets: { + selectors + } + } + } +} diff --git a/src/core/plugins/request-snippets/request-snippets.jsx b/src/core/plugins/request-snippets/request-snippets.jsx new file mode 100644 index 00000000000..12bb9b695b7 --- /dev/null +++ b/src/core/plugins/request-snippets/request-snippets.jsx @@ -0,0 +1,127 @@ +import React from "react" +import { CopyToClipboard } from "react-copy-to-clipboard" +import PropTypes from "prop-types" +import get from "lodash/get" +import {SyntaxHighlighter, getStyle} from "core/syntax-highlighting" + +export class RequestSnippets extends React.Component { + constructor() { + super() + this.state = { + activeLanguage: this.props?.requestSnippetsSelectors?.getSnippetGenerators()?.keySeq().first(), + expanded: this.props?.requestSnippetsSelectors?.getDefaultExpanded(), + } + } + + static propTypes = { + request: PropTypes.object.isRequired, + requestSnippetsSelectors: PropTypes.object.isRequired, + getConfigs: PropTypes.object.isRequired, + requestSnippetsActions: PropTypes.object.isRequired, + } + render() { + const {request, getConfigs, requestSnippetsSelectors } = this.props + const snippetGenerators = requestSnippetsSelectors.getSnippetGenerators() + const activeLanguage = this.state.activeLanguage || snippetGenerators.keySeq().first() + const activeGenerator = snippetGenerators.get(activeLanguage) + const snippet = activeGenerator.get("fn")(request) + const onGenChange = (key) => { + const needsChange = activeLanguage !== key + if(needsChange) { + this.setState({ + activeLanguage: key + }) + } + } + const style = { + cursor: "pointer", + lineHeight: 1, + display: "inline-flex", + backgroundColor: "rgb(250, 250, 250)", + paddingBottom: "0", + paddingTop: "0", + border: "1px solid rgb(51, 51, 51)", + borderRadius: "4px 4px 0 0", + boxShadow: "none", + borderBottom: "none" + } + const activeStyle = { + cursor: "pointer", + lineHeight: 1, + display: "inline-flex", + backgroundColor: "rgb(51, 51, 51)", + boxShadow: "none", + border: "1px solid rgb(51, 51, 51)", + paddingBottom: "0", + paddingTop: "0", + borderRadius: "4px 4px 0 0", + marginTop: "-5px", + marginRight: "-5px", + marginLeft: "-5px", + zIndex: "9999", + borderBottom: "none" + } + const getBtnStyle = (key) => { + if (key === activeLanguage) { + return activeStyle + } + return style + } + const config = getConfigs() + + const SnippetComponent = config?.syntaxHighlight?.activated + ? + {snippet} + + : + + + const expanded = this.state.expanded === undefined ? this.props?.requestSnippetsSelectors?.getDefaultExpanded() : this.state.expanded + return ( +
+
+

this.setState({expanded: !expanded})} + >Snippets

+ +
+ { + expanded &&
+
+ { + snippetGenerators.map((gen, key) => { + return (
onGenChange(key)}> +

{gen.get("title")}

+
) + }) + } +
+
+ +
+
+ {SnippetComponent} +
+
+ } +
+ + ) + } +} diff --git a/src/core/plugins/request-snippets/selectors.js b/src/core/plugins/request-snippets/selectors.js new file mode 100644 index 00000000000..396f7295209 --- /dev/null +++ b/src/core/plugins/request-snippets/selectors.js @@ -0,0 +1,45 @@ +import { createSelector } from "reselect" +import { Map } from "immutable" + +const state = state => state || Map() + +export const getGenerators = createSelector( + state, + state => { + const languageKeys = state + .get("languages") + const generators = state + .get("generators", Map()) + if(!languageKeys) { + return generators + } + return generators + .filter((v, key) => languageKeys.includes(key)) + } +) + +export const getSnippetGenerators = (state) => ({ fn }) => { + const getGenFn = (key) => fn[`requestSnippetGenerator_${key}`] + return getGenerators(state) + .map((gen, key) => { + const genFn = getGenFn(key) + if(typeof genFn !== "function") { + return null + } + + return gen.set("fn", genFn) + }) + .filter(v => v) +} + +export const getActiveLanguage = createSelector( + state, + state => state + .get("activeLanguage") +) + +export const getDefaultExpanded = createSelector( + state, + state => state + .get("defaultExpanded") +) diff --git a/src/core/presets/base.js b/src/core/presets/base.js index 73565cdf7cd..8462a5e1963 100644 --- a/src/core/presets/base.js +++ b/src/core/presets/base.js @@ -3,6 +3,7 @@ import layout from "core/plugins/layout" import spec from "core/plugins/spec" import view from "core/plugins/view" import samples from "core/plugins/samples" +import requestSnippets from "core/plugins/request-snippets" import logs from "core/plugins/logs" import swaggerJs from "core/plugins/swagger-js" import auth from "core/plugins/auth" @@ -64,7 +65,6 @@ import JumpToPath from "core/components/jump-to-path" import Footer from "core/components/footer" import FilterContainer from "core/containers/filter" import ParamBody from "core/components/param-body" -import Curl from "core/components/curl" import Schemes from "core/components/schemes" import SchemesContainer from "core/containers/schemes" import ModelCollapse from "core/components/model-collapse" @@ -132,7 +132,6 @@ export default function() { footer: Footer, FilterContainer, ParamBody: ParamBody, - curl: Curl, schemes: Schemes, SchemesContainer, modelExample: ModelExample, @@ -191,6 +190,7 @@ export default function() { downloadUrlPlugin, deepLinkingPlugin, filter, - onComplete + onComplete, + requestSnippets ] } diff --git a/src/core/syntax-highlighting.js b/src/core/syntax-highlighting.js index e3e760c5f1b..25e7c31185b 100644 --- a/src/core/syntax-highlighting.js +++ b/src/core/syntax-highlighting.js @@ -6,6 +6,8 @@ import xml from "react-syntax-highlighter/dist/esm/languages/hljs/xml" import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash" import yaml from "react-syntax-highlighter/dist/esm/languages/hljs/yaml" import http from "react-syntax-highlighter/dist/esm/languages/hljs/http" +import powershell from "react-syntax-highlighter/dist/esm/languages/hljs/powershell" +import javascript from "react-syntax-highlighter/dist/esm/languages/hljs/javascript" import agate from "react-syntax-highlighter/dist/esm/styles/hljs/agate" import arta from "react-syntax-highlighter/dist/esm/styles/hljs/arta" @@ -20,6 +22,8 @@ SyntaxHighlighter.registerLanguage("xml", xml) SyntaxHighlighter.registerLanguage("yaml", yaml) SyntaxHighlighter.registerLanguage("http", http) SyntaxHighlighter.registerLanguage("bash", bash) +SyntaxHighlighter.registerLanguage("powershell", powershell) +SyntaxHighlighter.registerLanguage("javascript", javascript) const styles = {agate, arta, monokai, nord, obsidian, "tomorrow-night": tomorrowNight} export const availableStyles = Object.keys(styles)