feat: request snippets plugin (#6910)

This commit is contained in:
Mahtis Michel
2021-03-10 20:02:34 +01:00
committed by GitHub
parent 15b8c0c929
commit 8405fa0101
18 changed files with 537 additions and 210 deletions

View File

@@ -1,9 +1,9 @@
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"
import { requestSnippetGenerator_curl_bash } from "../plugins/request-snippets/fn"
export default class Curl extends React.Component {
static propTypes = {
@@ -13,7 +13,7 @@ export default class Curl extends React.Component {
render() {
let { request, getConfigs } = this.props
let curl = curlify(request)
let curl = requestSnippetGenerator_curl_bash(request)
const config = getConfigs()

View File

@@ -49,7 +49,7 @@ export default class LiveResponse extends React.Component {
render() {
const { response, getComponent, getConfigs, displayRequestDuration, specSelectors, path, method } = this.props
const { showMutatedRequest } = getConfigs()
const { showMutatedRequest, requestSnippetsEnabled } = getConfigs()
const curlRequest = showMutatedRequest ? specSelectors.mutatedRequestFor(path, method) : specSelectors.requestFor(path, method)
const status = response.get("status")
@@ -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,14 @@ export default class LiveResponse extends React.Component {
})
const hasHeaders = returnObject.length !== 0
const Markdown = getComponent("Markdown", true)
const RequestSnippets = getComponent("RequestSnippets", true)
const Curl = getComponent("curl")
return (
<div>
{ curlRequest && <Curl request={ curlRequest } getConfigs={ getConfigs } /> }
{ curlRequest && (requestSnippetsEnabled === true || requestSnippetsEnabled === "true"
? <RequestSnippets request={ curlRequest }/>
: <Curl request={ curlRequest } getConfigs={ getConfigs } />) }
{ url && <div>
<h4>Request URL</h4>
<div className="request-url">

View File

@@ -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( " " )
}

View File

@@ -53,6 +53,29 @@ export default function SwaggerUI(opts) {
showExtensions: false,
showCommonExtensions: false,
withCredentials: undefined,
requestSnippetsEnabled: false,
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 +130,8 @@ export default function SwaggerUI(opts) {
spec: {
spec: "",
url: constructorConfig.url
}
},
requestSnippets: constructorConfig.requestSnippets
}, constructorConfig.initialState)
}

View File

@@ -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();`
}

View File

@@ -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
}
}
}
}

View File

@@ -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
? <SyntaxHighlighter
language={activeGenerator.get("syntax")}
className="curl microlight"
onWheel={function(e) {return this.preventYScrollingBeyondElement(e)}}
style={getStyle(get(config, "syntaxHighlight.theme"))}
>
{snippet}
</SyntaxHighlighter>
:
<textarea readOnly={true} className="curl" value={snippet}></textarea>
const expanded = this.state.expanded === undefined ? this.props?.requestSnippetsSelectors?.getDefaultExpanded() : this.state.expanded
return (
<div>
<div style={{width: "100%", display: "flex", justifyContent: "flex-start", alignItems: "center", marginBottom: "15px"}}>
<h4
style={{ cursor: "pointer" }}
onClick={() => this.setState({expanded: !expanded})}
>Snippets</h4>
<button
onClick={() => this.setState({expanded: !expanded})}
style={{ border: "none", background: "none" }}
title={expanded ? "Collapse operation": "Expand operation"}
>
<svg className="arrow" width="10" height="10">
<use href={expanded ? "#large-arrow-down" : "#large-arrow"} xlinkHref={expanded ? "#large-arrow-down" : "#large-arrow"} />
</svg>
</button>
</div>
{
expanded && <div className="curl-command">
<div style={{paddingLeft: "15px", paddingRight: "10px", width: "100%", display: "flex"}}>
{
snippetGenerators.map((gen, key) => {
return (<div style={getBtnStyle(key)} className="btn" key={key} onClick={() => onGenChange(key)}>
<h4 style={key === activeLanguage ? {color: "white",} : {}}>{gen.get("title")}</h4>
</div>)
})
}
</div>
<div className="copy-to-clipboard">
<CopyToClipboard text={snippet}>
<button />
</CopyToClipboard>
</div>
<div>
{SnippetComponent}
</div>
</div>
}
</div>
)
}
}

View File

@@ -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")
)

View File

@@ -5,7 +5,7 @@ import serializeError from "serialize-error"
import isString from "lodash/isString"
import debounce from "lodash/debounce"
import set from "lodash/set"
import { isJSONObject, paramToValue, isEmptyValue } from "core/utils"
import { paramToValue, isEmptyValue } from "core/utils"
// Actions conform to FSA (flux-standard-actions)
// {type: string,payload: Any|Error, meta: obj, error: bool}
@@ -426,9 +426,7 @@ export const executeRequest = (req) =>
const requestBody = oas3Selectors.requestBodyValue(pathName, method)
const requestBodyInclusionSetting = oas3Selectors.requestBodyInclusionSetting(pathName, method)
if(isJSONObject(requestBody)) {
req.requestBody = JSON.parse(requestBody)
} else if(requestBody && requestBody.toJS) {
if(requestBody && requestBody.toJS) {
req.requestBody = requestBody
.map(
(val) => {
@@ -445,7 +443,7 @@ export const executeRequest = (req) =>
) || requestBodyInclusionSetting.get(key)
)
.toJS()
} else{
} else {
req.requestBody = requestBody
}
}

View File

@@ -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"
@@ -191,6 +192,7 @@ export default function() {
downloadUrlPlugin,
deepLinkingPlugin,
filter,
onComplete
onComplete,
requestSnippets
]
}

View File

@@ -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)

View File

@@ -32,25 +32,6 @@ const DEFAULT_RESPONSE_KEY = "default"
export const isImmutable = (maybe) => Im.Iterable.isIterable(maybe)
export function isJSONObject (str) {
try {
var o = JSON.parse(str)
// Handle non-exception-throwing cases:
// Neither JSON.parse(false) or JSON.parse(1234) throw errors, hence the type-checking,
// but... JSON.parse(null) returns null, and typeof null === "object",
// so we must check for that, too. Thankfully, null is falsey, so this suffices:
if (o && typeof o === "object") {
return o
}
}
catch (e) {
// do nothing
}
return false
}
export function objectify (thing) {
if(!isObject(thing))
return {}