fix: parsing of protocol correctly (#2088)
Co-authored-by: Liyas Thomas <hi@liyasthomas.com> Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com> Co-authored-by: liyasthomas <liyascthomas@gmail.com> Co-authored-by: Rishabh Agarwal <45998880+RishabhAgarwal-2001@users.noreply.github.com>
This commit is contained in:
@@ -34,15 +34,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "@nuxtjs/composition-api"
|
||||
import {
|
||||
HoppRESTHeader,
|
||||
HoppRESTParam,
|
||||
makeRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import parseCurlCommand from "~/helpers/curlparser"
|
||||
import { useCodemirror } from "~/helpers/editor/codemirror"
|
||||
import { setRESTRequest } from "~/newstore/RESTSession"
|
||||
import { useI18n, useToast } from "~/helpers/utils/composables"
|
||||
import { parseCurlToHoppRESTReq } from "~/helpers/curl"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -85,66 +80,9 @@ const hideModal = () => {
|
||||
const handleImport = () => {
|
||||
const text = curl.value
|
||||
try {
|
||||
const parsedCurl = parseCurlCommand(text)
|
||||
const { origin, pathname } = new URL(
|
||||
parsedCurl.url.replace(/"/g, "").replace(/'/g, "")
|
||||
)
|
||||
const endpoint = origin + pathname
|
||||
const headers: HoppRESTHeader[] = []
|
||||
const params: HoppRESTParam[] = []
|
||||
const body = parsedCurl.body
|
||||
if (parsedCurl.query) {
|
||||
for (const key of Object.keys(parsedCurl.query)) {
|
||||
const val = parsedCurl.query[key]!
|
||||
const req = parseCurlToHoppRESTReq(text)
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach((value) => {
|
||||
params.push({
|
||||
key,
|
||||
value,
|
||||
active: true,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
params.push({
|
||||
key,
|
||||
value: val!,
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (parsedCurl.headers) {
|
||||
for (const key of Object.keys(parsedCurl.headers)) {
|
||||
headers.push({
|
||||
key,
|
||||
value: parsedCurl.headers[key],
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const method = parsedCurl.method.toUpperCase()
|
||||
|
||||
setRESTRequest(
|
||||
makeRESTRequest({
|
||||
name: "Untitled request",
|
||||
endpoint,
|
||||
method,
|
||||
params,
|
||||
headers,
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
auth: {
|
||||
authType: "none",
|
||||
authActive: true,
|
||||
},
|
||||
body: {
|
||||
contentType: "application/json",
|
||||
body,
|
||||
},
|
||||
})
|
||||
)
|
||||
setRESTRequest(req)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(`${t("error.curl_invalid_format")}`)
|
||||
|
||||
274
packages/hoppscotch-app/helpers/curl/contentParser.ts
Normal file
274
packages/hoppscotch-app/helpers/curl/contentParser.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { HoppRESTReqBody } from "@hoppscotch/data"
|
||||
import * as S from "fp-ts/string"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { tupleToRecord } from "~/helpers/functional/record"
|
||||
import { safeParseJSON } from "~/helpers/functional/json"
|
||||
|
||||
/**
|
||||
* Detects the content type of the input string
|
||||
* @param rawData String for which content type is to be detected
|
||||
* @returns Content type of the data
|
||||
*/
|
||||
export function detectContentType(
|
||||
rawData: string
|
||||
): HoppRESTReqBody["contentType"] {
|
||||
let contentType: HoppRESTReqBody["contentType"]
|
||||
|
||||
if (O.isSome(safeParseJSON(rawData))) {
|
||||
contentType = "application/json"
|
||||
} else if (/<\/?[a-zA-Z][\s\S]*>/i.test(rawData)) {
|
||||
if (O.isSome(prettifyXml(rawData))) {
|
||||
contentType = "application/xml"
|
||||
} else {
|
||||
// everything is HTML
|
||||
contentType = "text/html"
|
||||
}
|
||||
} else if (/([^&=]+)=([^&=]+)/.test(rawData)) {
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
} else {
|
||||
contentType = pipe(
|
||||
rawData.match(/^-{2,}.+\\r\\n/),
|
||||
O.fromNullable,
|
||||
O.filter((boundaryMatch) => boundaryMatch && boundaryMatch.length > 1),
|
||||
O.match(
|
||||
() => "text/plain",
|
||||
() => "multipart/form-data"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return contentType
|
||||
}
|
||||
|
||||
/**
|
||||
* Prettifies XML string
|
||||
* @param sourceXml The string to format
|
||||
* @returns Indented XML string (uses spaces)
|
||||
*/
|
||||
const prettifyXml = (sourceXml: string) =>
|
||||
pipe(
|
||||
O.tryCatch(() => {
|
||||
const xmlDoc = new DOMParser().parseFromString(
|
||||
sourceXml,
|
||||
"application/xml"
|
||||
)
|
||||
|
||||
if (xmlDoc.querySelector("parsererror")) {
|
||||
throw new Error("Unstructured Body")
|
||||
}
|
||||
|
||||
const xsltDoc = new DOMParser().parseFromString(
|
||||
[
|
||||
// describes how we want to modify the XML - indent everything
|
||||
'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform">',
|
||||
' <xsl:strip-space elements="*"/>',
|
||||
' <xsl:template match="para[content-style][not(text())]">', // change to just text() to strip space in text nodes
|
||||
' <xsl:value-of select="normalize-space(.)"/>',
|
||||
" </xsl:template>",
|
||||
' <xsl:template match="node()|@*">',
|
||||
' <xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>',
|
||||
" </xsl:template>",
|
||||
' <xsl:output indent="yes"/>',
|
||||
"</xsl:stylesheet>",
|
||||
].join("\n"),
|
||||
"application/xml"
|
||||
)
|
||||
|
||||
const xsltProcessor = new XSLTProcessor()
|
||||
xsltProcessor.importStylesheet(xsltDoc)
|
||||
const resultDoc = xsltProcessor.transformToDocument(xmlDoc)
|
||||
const resultXml = new XMLSerializer().serializeToString(resultDoc)
|
||||
|
||||
return resultXml
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Prettifies HTML string
|
||||
* @param htmlString The string to format
|
||||
* @returns Indented HTML string (uses spaces)
|
||||
*/
|
||||
const formatHTML = (htmlString: string) => {
|
||||
const tab = " "
|
||||
let result = ""
|
||||
let indent = ""
|
||||
const emptyTags = [
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]
|
||||
|
||||
const spl = htmlString.split(/>\s*</)
|
||||
spl.forEach((element) => {
|
||||
if (element.match(/^\/\w/)) {
|
||||
indent = indent.substring(tab.length)
|
||||
}
|
||||
|
||||
result += indent + "<" + element + ">\n"
|
||||
|
||||
if (
|
||||
element.match(/^<?\w[^>]*[^/]$/) &&
|
||||
!emptyTags.includes(element.match(/^([a-z]*)/i)?.at(1) || "")
|
||||
) {
|
||||
indent += tab
|
||||
}
|
||||
})
|
||||
|
||||
return result.substring(1, result.length - 2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses provided string according to the content type
|
||||
* @param rawData Data to be parsed
|
||||
* @param contentType Content type of the data
|
||||
* @param boundary Optional parameter required for multipart/form-data content type
|
||||
* @returns Option of parsed body as string or Record object for multipart/form-data
|
||||
*/
|
||||
export function parseBody(
|
||||
rawData: string,
|
||||
contentType: HoppRESTReqBody["contentType"],
|
||||
rawContentType?: string
|
||||
): O.Option<string | Record<string, string>> {
|
||||
switch (contentType) {
|
||||
case "application/hal+json":
|
||||
case "application/ld+json":
|
||||
case "application/vnd.api+json":
|
||||
case "application/json": {
|
||||
return pipe(
|
||||
rawData,
|
||||
safeParseJSON,
|
||||
O.map((parsedJSON) => JSON.stringify(parsedJSON, null, 2)),
|
||||
O.getOrElse(() => "{}"),
|
||||
O.fromNullable
|
||||
)
|
||||
}
|
||||
|
||||
case "application/x-www-form-urlencoded": {
|
||||
return pipe(
|
||||
rawData,
|
||||
O.fromNullable,
|
||||
O.map(decodeURIComponent),
|
||||
O.chain((rd) =>
|
||||
pipe(rd.match(/(([^&=]+)=?([^&=]+))/g), O.fromNullable)
|
||||
),
|
||||
O.map((pairs) => pairs.map((p) => p.replace("=", ": ")).join("\n"))
|
||||
)
|
||||
}
|
||||
|
||||
case "multipart/form-data": {
|
||||
/**
|
||||
* O.bind binds "boundary"
|
||||
* If rawContentType is present, try to extract boundary from it
|
||||
* If rawContentTpe is not present, try to regex match the boundary from rawData
|
||||
* In case both the above attempts fail, O.map is not executed and the pipe is
|
||||
* short-circuited. O.none is returned.
|
||||
*
|
||||
* In the event the boundary is ascertained, process rawData to get key-value
|
||||
* pairs and convert them to a tuple array. If the array is not empty,
|
||||
* convert it to Record<string, string> type and return O.some of it.
|
||||
*/
|
||||
return pipe(
|
||||
O.Do,
|
||||
|
||||
O.bind("boundary", () =>
|
||||
pipe(
|
||||
rawContentType,
|
||||
O.fromNullable,
|
||||
O.match(
|
||||
() =>
|
||||
pipe(
|
||||
rawData.match(/^-{2,}.+\\r\\n/),
|
||||
O.fromNullable,
|
||||
O.filter((boundaryMatch) => boundaryMatch.length > 1),
|
||||
O.map((matches) => matches[0])
|
||||
),
|
||||
(rct) =>
|
||||
pipe(
|
||||
rct.match(/boundary=(.+)/),
|
||||
O.fromNullable,
|
||||
O.filter(
|
||||
(boundaryContentMatch) => boundaryContentMatch.length > 1
|
||||
),
|
||||
O.filter((matches) =>
|
||||
rawData
|
||||
.replaceAll("\\r\\n", "")
|
||||
.endsWith("--" + matches[1] + "--")
|
||||
),
|
||||
O.map((matches) => "--" + matches[1])
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
O.map(({ boundary }) =>
|
||||
pipe(
|
||||
rawData,
|
||||
S.split(boundary),
|
||||
RA.filter((p) => p !== "" && p.includes("name")),
|
||||
RA.map((p) =>
|
||||
pipe(
|
||||
p.replaceAll(/[\r\n]+/g, "\r\n"),
|
||||
S.split("\\r\\n"),
|
||||
RA.filter((q) => q !== "")
|
||||
)
|
||||
),
|
||||
RA.filterMap((p) =>
|
||||
pipe(
|
||||
p[0].match(/name=(.+)$/),
|
||||
O.fromNullable,
|
||||
O.filter((nameMatch) => nameMatch.length > 0),
|
||||
O.map((nameMatch) => {
|
||||
const name = nameMatch[0]
|
||||
.replaceAll(/"/g, "")
|
||||
.split("=", 2)[1]
|
||||
return [name, p[0].includes("filename") ? "" : p[1]] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
})
|
||||
)
|
||||
),
|
||||
RA.toArray
|
||||
)
|
||||
),
|
||||
|
||||
O.filter((arr) => arr.length > 0),
|
||||
O.map(tupleToRecord)
|
||||
)
|
||||
}
|
||||
|
||||
case "text/html": {
|
||||
return pipe(rawData, O.fromNullable, O.map(formatHTML))
|
||||
}
|
||||
|
||||
case "application/xml": {
|
||||
return pipe(
|
||||
rawData,
|
||||
O.fromNullable,
|
||||
O.chain(prettifyXml),
|
||||
O.match(
|
||||
() => rawData,
|
||||
(res) => res
|
||||
),
|
||||
O.fromNullable
|
||||
)
|
||||
}
|
||||
|
||||
case "text/plain":
|
||||
default:
|
||||
return O.some(rawData)
|
||||
}
|
||||
}
|
||||
666
packages/hoppscotch-app/helpers/curl/curlparser.ts
Normal file
666
packages/hoppscotch-app/helpers/curl/curlparser.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
import * as cookie from "cookie"
|
||||
import parser from "yargs-parser"
|
||||
import * as RA from "fp-ts/ReadonlyArray"
|
||||
import * as O from "fp-ts/Option"
|
||||
import { pipe } from "fp-ts/function"
|
||||
|
||||
import {
|
||||
HoppRESTAuth,
|
||||
FormDataKeyValue,
|
||||
HoppRESTReqBody,
|
||||
makeRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
import { detectContentType, parseBody } from "./contentParser"
|
||||
import { CurlParserRequest } from "."
|
||||
import { tupleToRecord } from "~/helpers/functional/record"
|
||||
import { stringArrayJoin } from "~/helpers/functional/array"
|
||||
|
||||
export const parseCurlCommand = (curlCommand: string) => {
|
||||
const isDataBinary = curlCommand.includes(" --data-binary")
|
||||
|
||||
curlCommand = preProcessCurlCommand(curlCommand)
|
||||
const parsedArguments = parser(curlCommand)
|
||||
|
||||
const headers = getHeaders(parsedArguments)
|
||||
let rawContentType: string = ""
|
||||
|
||||
if (headers && rawContentType === "")
|
||||
rawContentType = headers["Content-Type"] || headers["content-type"] || ""
|
||||
|
||||
let rawData: string | string[] = parsedArguments?.d || ""
|
||||
const urlObject = parseURL(parsedArguments)
|
||||
|
||||
let { queries, danglingParams } = getQueries(
|
||||
urlObject?.searchParams.entries()
|
||||
)
|
||||
|
||||
// if method type is to be set as GET
|
||||
if (parsedArguments.G && Array.isArray(rawData)) {
|
||||
const pairs = getParamPairs(rawData)
|
||||
const newQueries = getQueries(pairs as [string, string][])
|
||||
queries = [...queries, ...newQueries.queries]
|
||||
danglingParams = [...danglingParams, ...newQueries.danglingParams]
|
||||
}
|
||||
const urlString = concatParams(urlObject?.origin, danglingParams) || ""
|
||||
|
||||
let multipartUploads: Record<string, string> = pipe(
|
||||
parsedArguments,
|
||||
O.fromNullable,
|
||||
O.chain(getFArgumentMultipartData),
|
||||
O.match(
|
||||
() => ({}),
|
||||
(args) => {
|
||||
rawContentType = "multipart/form-data"
|
||||
return args
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const auth = getAuthObject(parsedArguments, headers, urlObject)
|
||||
|
||||
let cookies: Record<string, string> | undefined
|
||||
|
||||
const cookieString = parsedArguments.b || parsedArguments.cookie || ""
|
||||
if (cookieString) {
|
||||
const cookieParseOptions = {
|
||||
decode: (s: any) => s,
|
||||
}
|
||||
// separate out cookie headers into separate data structure
|
||||
// note: cookie is case insensitive
|
||||
cookies = cookie.parse(
|
||||
cookieString.replace(/^Cookie: /gi, ""),
|
||||
cookieParseOptions
|
||||
)
|
||||
}
|
||||
|
||||
const method = getMethod(parsedArguments)
|
||||
let body: string | null = ""
|
||||
let contentType: HoppRESTReqBody["contentType"] = null
|
||||
|
||||
// just in case
|
||||
if (Array.isArray(rawData)) rawData = rawData.join("")
|
||||
|
||||
// if -F is not present, look for content type header
|
||||
// -G is used to send --data as get params
|
||||
if (rawContentType !== "multipart/form-data" && !parsedArguments.G) {
|
||||
const tempBody = pipe(
|
||||
O.Do,
|
||||
|
||||
O.bind("rct", () =>
|
||||
pipe(
|
||||
rawContentType,
|
||||
O.fromNullable,
|
||||
O.filter(() => !!headers && rawContentType !== "")
|
||||
)
|
||||
),
|
||||
|
||||
O.bind("cType", ({ rct }) =>
|
||||
pipe(
|
||||
rct,
|
||||
O.fromNullable,
|
||||
O.map((RCT) => RCT.toLowerCase()),
|
||||
O.map((RCT) => RCT.split(";")[0]),
|
||||
O.map((RCT) => RCT as HoppRESTReqBody["contentType"])
|
||||
)
|
||||
),
|
||||
|
||||
O.bind("rData", () =>
|
||||
pipe(
|
||||
rawData as string,
|
||||
O.fromNullable,
|
||||
O.filter(() => !!rawData && rawData.length > 0)
|
||||
)
|
||||
),
|
||||
|
||||
O.bind("ctBody", ({ rct, cType, rData }) =>
|
||||
pipe(rData, getBodyFromContentType(rct, cType))
|
||||
)
|
||||
)
|
||||
|
||||
if (O.isSome(tempBody)) {
|
||||
const { cType, ctBody } = tempBody.value
|
||||
contentType = cType
|
||||
if (typeof ctBody === "string") body = ctBody
|
||||
else multipartUploads = ctBody
|
||||
} else if (
|
||||
!(
|
||||
rawContentType &&
|
||||
rawContentType.startsWith("multipart/form-data") &&
|
||||
rawContentType.includes("boundary")
|
||||
)
|
||||
) {
|
||||
const newTempBody = pipe(
|
||||
rawData,
|
||||
O.fromNullable,
|
||||
O.filter(() => !!rawData && rawData.length > 0),
|
||||
O.chain(getBodyWithoutContentType)
|
||||
)
|
||||
|
||||
if (O.isSome(newTempBody)) {
|
||||
const { cType, proData } = newTempBody.value
|
||||
contentType = cType
|
||||
if (typeof proData === "string") body = proData
|
||||
else multipartUploads = proData
|
||||
}
|
||||
} else {
|
||||
body = null
|
||||
contentType = null
|
||||
}
|
||||
}
|
||||
|
||||
const compressed = !!parsedArguments.compressed
|
||||
const hoppHeaders = recordToHoppHeaders(headers)
|
||||
|
||||
const request: CurlParserRequest = {
|
||||
urlString,
|
||||
urlObject,
|
||||
compressed,
|
||||
queries,
|
||||
hoppHeaders,
|
||||
method,
|
||||
contentType,
|
||||
body,
|
||||
cookies,
|
||||
cookieString: cookieString?.replace(/Cookie: /i, ""),
|
||||
multipartUploads,
|
||||
isDataBinary,
|
||||
auth,
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
// ############################################ //
|
||||
// ## HELPER FUNCTIONS ## //
|
||||
// ############################################ //
|
||||
|
||||
const replaceables: { [key: string]: string } = {
|
||||
"--request": "-X",
|
||||
"--header": "-H",
|
||||
"--url": "",
|
||||
"--form": "-F",
|
||||
"--data-raw": "--data",
|
||||
"--data": "-d",
|
||||
"--data-ascii": "-d",
|
||||
"--data-binary": "-d",
|
||||
"--user": "-u",
|
||||
"--get": "-G",
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes curl string
|
||||
* @param curlCommand Raw curl command string
|
||||
* @returns Processed curl command string
|
||||
*/
|
||||
function preProcessCurlCommand(curlCommand: string) {
|
||||
// remove '\' and newlines
|
||||
curlCommand = curlCommand.replace(/ ?\\ ?$/gm, " ")
|
||||
curlCommand = curlCommand.replace(/\n/g, "")
|
||||
|
||||
// remove all $ symbols from start of argument values
|
||||
curlCommand = curlCommand.replaceAll("$'", "'")
|
||||
curlCommand = curlCommand.replaceAll('$"', '"')
|
||||
|
||||
// replace string for insomnia
|
||||
for (const r in replaceables) {
|
||||
curlCommand = curlCommand.replace(
|
||||
RegExp(` ${r}(["' ])`),
|
||||
` ${replaceables[r]}$1`
|
||||
)
|
||||
}
|
||||
|
||||
// yargs parses -XPOST as separate arguments. just prescreen for it.
|
||||
curlCommand = curlCommand.replace(
|
||||
/ -X(GET|POST|PUT|PATCH|DELETE|HEAD|CONNECT|OPTIONS|TRACE|CUSTOM)/,
|
||||
" -X $1"
|
||||
)
|
||||
curlCommand = curlCommand.trim()
|
||||
|
||||
return curlCommand
|
||||
}
|
||||
|
||||
/** Parses body based on the content type
|
||||
* @param rct Raw content type
|
||||
* @param cType Sanitized content type
|
||||
* @returns Option of parsed body
|
||||
*/
|
||||
function getBodyFromContentType(
|
||||
rct: string,
|
||||
cType: HoppRESTReqBody["contentType"]
|
||||
) {
|
||||
return (rData: string) => {
|
||||
if (cType === "multipart/form-data")
|
||||
// put body to multipartUploads in post processing
|
||||
return pipe(
|
||||
parseBody(rData, cType, rct),
|
||||
O.filter((parsedBody) => typeof parsedBody !== "string")
|
||||
)
|
||||
else
|
||||
return pipe(
|
||||
parseBody(rData, cType),
|
||||
O.filter(
|
||||
(parsedBody) =>
|
||||
typeof parsedBody === "string" && parsedBody.length > 0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects and parses body without the help of content type
|
||||
* @param rawData Raw body string
|
||||
* @returns Option of raw data, detected content type and parsed data
|
||||
*/
|
||||
function getBodyWithoutContentType(rawData: string) {
|
||||
return pipe(
|
||||
O.Do,
|
||||
|
||||
O.bind("rData", () =>
|
||||
pipe(
|
||||
rawData,
|
||||
O.fromNullable,
|
||||
O.filter((rd) => rd.length > 0)
|
||||
)
|
||||
),
|
||||
|
||||
O.bind("cType", ({ rData }) =>
|
||||
pipe(rData, detectContentType, O.fromNullable)
|
||||
),
|
||||
|
||||
O.bind("proData", ({ cType, rData }) => parseBody(rData, cType))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes URL string and returns the URL object
|
||||
* @param parsedArguments Parsed Arguments object
|
||||
* @returns URL object
|
||||
*/
|
||||
function parseURL(parsedArguments: parser.Arguments) {
|
||||
return pipe(
|
||||
parsedArguments?._[1],
|
||||
O.fromNullable,
|
||||
O.map((u) => u.replace(/["']/g, "")),
|
||||
O.map((u) => u.trim()),
|
||||
O.chain((u) =>
|
||||
pipe(
|
||||
/^[^:\s]+(?=:\/\/)/.exec(u),
|
||||
O.fromNullable,
|
||||
O.map((p) => p[2]),
|
||||
O.match(
|
||||
// if protocol is not found
|
||||
() =>
|
||||
pipe(
|
||||
// get the base URL
|
||||
/^([^\s:@]+:[^\s:@]+@)?([^:/\s]+)([:]*)/.exec(u),
|
||||
O.fromNullable,
|
||||
O.map((burl) => burl[2]),
|
||||
O.map((burl) =>
|
||||
burl === "localhost" || burl === "127.0.0.1"
|
||||
? "http://" + u
|
||||
: "https://" + u
|
||||
)
|
||||
),
|
||||
(_) => O.some(u)
|
||||
)
|
||||
)
|
||||
),
|
||||
O.map((u) => new URL(u)),
|
||||
O.getOrElse(() => {
|
||||
// no url found
|
||||
for (const argName in parsedArguments) {
|
||||
if (
|
||||
typeof parsedArguments[argName] === "string" &&
|
||||
["http", "www."].includes(parsedArguments[argName])
|
||||
)
|
||||
return pipe(
|
||||
parsedArguments[argName],
|
||||
O.fromNullable,
|
||||
O.map((u) => new URL(u)),
|
||||
O.match(
|
||||
() => undefined,
|
||||
(u) => u
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts queries to HoppRESTParam format and separates dangling ones
|
||||
* @param queries Array or IterableIterator of key value pairs of queries
|
||||
* @returns Queries formatted compatible to HoppRESTParam and list of dangling params
|
||||
*/
|
||||
function getQueries(
|
||||
searchParams:
|
||||
| [string, string][]
|
||||
| IterableIterator<[string, string]>
|
||||
| undefined
|
||||
) {
|
||||
const danglingParams: string[] = []
|
||||
const queries = pipe(
|
||||
searchParams,
|
||||
O.fromNullable,
|
||||
O.map((iter) => {
|
||||
const params = []
|
||||
|
||||
for (const q of iter) {
|
||||
if (q[1] === "") {
|
||||
danglingParams.push(q[0])
|
||||
continue
|
||||
}
|
||||
params.push({
|
||||
key: q[0],
|
||||
value: q[1],
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
return params
|
||||
}),
|
||||
|
||||
O.getOrElseW(() => [])
|
||||
)
|
||||
|
||||
return {
|
||||
queries,
|
||||
danglingParams,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins dangling params to origin
|
||||
* @param origin origin value from the URL Object
|
||||
* @param params params without values
|
||||
* @returns origin string concatenated with dngling paramas
|
||||
*/
|
||||
function concatParams(origin: string | undefined, params: string[]) {
|
||||
return pipe(
|
||||
O.Do,
|
||||
|
||||
O.bind("originString", () =>
|
||||
pipe(
|
||||
origin,
|
||||
O.fromNullable,
|
||||
O.filter((h) => h !== "")
|
||||
)
|
||||
),
|
||||
|
||||
O.map(({ originString }) =>
|
||||
pipe(
|
||||
params,
|
||||
O.fromNullable,
|
||||
O.filter((dp) => dp.length > 0),
|
||||
O.map(stringArrayJoin("&")),
|
||||
O.map((h) => originString + "?" + h),
|
||||
O.getOrElse(() => originString)
|
||||
)
|
||||
),
|
||||
|
||||
O.getOrElse(() => "")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and structures multipart/form-data from -F argument of curl command
|
||||
* @param parsedArguments Parsed Arguments object
|
||||
* @returns Option of Record<string, string> type containing key-value pairs of multipart/form-data
|
||||
*/
|
||||
function getFArgumentMultipartData(
|
||||
parsedArguments: parser.Arguments
|
||||
): O.Option<Record<string, string>> {
|
||||
// -F multipart data
|
||||
|
||||
return pipe(
|
||||
parsedArguments.F as Array<string> | string | undefined,
|
||||
O.fromNullable,
|
||||
O.map((fArgs) => (Array.isArray(fArgs) ? fArgs : [fArgs])),
|
||||
O.map((fArgs: string[]) =>
|
||||
pipe(
|
||||
fArgs.map((multipartArgument: string) => {
|
||||
const [key, value] = multipartArgument.split("=", 2)
|
||||
|
||||
if (parsedArguments["form-string"])
|
||||
return [key, value] as [string, string]
|
||||
return [key, value[0] === "@" || value[0] === "<" ? "" : value] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
}),
|
||||
RA.toArray,
|
||||
tupleToRecord
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get method type from X argument in curl string or
|
||||
* find it out through presence of other arguments
|
||||
* @param parsedArguments Parsed Arguments object
|
||||
* @returns Method type
|
||||
*/
|
||||
function getMethod(parsedArguments: parser.Arguments): string {
|
||||
const Xarg: string = parsedArguments.X
|
||||
return pipe(
|
||||
Xarg?.match(/GET|POST|PUT|PATCH|DELETE|HEAD|CONNECT|OPTIONS|TRACE|CUSTOM/i),
|
||||
O.fromNullable,
|
||||
O.match(
|
||||
() => {
|
||||
if (parsedArguments.T) return "put"
|
||||
else if (parsedArguments.I || parsedArguments.head) return "head"
|
||||
else if (
|
||||
parsedArguments.d ||
|
||||
(parsedArguments.F && !(parsedArguments.G || parsedArguments.get))
|
||||
)
|
||||
return "post"
|
||||
else return "get"
|
||||
},
|
||||
(method) => method[0]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getHeaders(parsedArguments: parser.Arguments) {
|
||||
let headers: Record<string, string> = {}
|
||||
|
||||
headers = pipe(
|
||||
parsedArguments.H,
|
||||
O.fromNullable,
|
||||
O.map((h) => (Array.isArray(h) ? h : [h])),
|
||||
O.map((h: string[]) =>
|
||||
pipe(
|
||||
h.map((header: string) => {
|
||||
const [key, value] = header.split(": ")
|
||||
return [key.trim(), value.trim()] as [string, string]
|
||||
}),
|
||||
RA.toArray,
|
||||
tupleToRecord
|
||||
)
|
||||
),
|
||||
O.match(
|
||||
() => ({}),
|
||||
(h) => h
|
||||
)
|
||||
)
|
||||
|
||||
const userAgent = parsedArguments.A || parsedArguments["user-agent"]
|
||||
if (userAgent) headers["User-Agent"] = userAgent
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
function recordToHoppHeaders(headers: Record<string, string>) {
|
||||
const hoppHeaders = []
|
||||
for (const key of Object.keys(headers)) {
|
||||
hoppHeaders.push({
|
||||
key,
|
||||
value: headers[key],
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
return hoppHeaders
|
||||
}
|
||||
|
||||
function getParamPairs(rawdata: string[]) {
|
||||
return pipe(
|
||||
rawdata,
|
||||
O.fromNullable,
|
||||
O.map((p) => p.map(decodeURIComponent)),
|
||||
O.map((pairs) => pairs.map((pair) => pair.split("="))),
|
||||
O.getOrElseW(() => undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function getAuthObject(
|
||||
parsedArguments: parser.Arguments,
|
||||
headers: Record<string, string>,
|
||||
urlObject: URL | undefined
|
||||
): HoppRESTAuth {
|
||||
// >> preference order:
|
||||
// - Auth headers
|
||||
// - apikey headers
|
||||
// - --user arg
|
||||
// - Creds provided along with URL
|
||||
|
||||
let auth: HoppRESTAuth = {
|
||||
authActive: false,
|
||||
authType: "none",
|
||||
}
|
||||
let username: string = ""
|
||||
let password: string = ""
|
||||
|
||||
if (headers?.Authorization) {
|
||||
auth = pipe(
|
||||
headers?.Authorization,
|
||||
O.fromNullable,
|
||||
O.map((a) => a.split(" ")),
|
||||
O.filter((a) => a.length > 0),
|
||||
O.chain((kv) =>
|
||||
pipe(
|
||||
(() => {
|
||||
switch (kv[0].toLowerCase()) {
|
||||
case "bearer":
|
||||
return {
|
||||
authActive: true,
|
||||
authType: "bearer",
|
||||
token: kv[1],
|
||||
}
|
||||
case "apikey":
|
||||
return {
|
||||
authActive: true,
|
||||
authType: "api-key",
|
||||
key: "apikey",
|
||||
value: kv[1],
|
||||
addTo: "headers",
|
||||
}
|
||||
case "basic": {
|
||||
const buffer = Buffer.from(kv[1], "base64")
|
||||
const [username, password] = buffer.toString().split(":")
|
||||
return {
|
||||
authActive: true,
|
||||
authType: "basic",
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
O.fromNullable
|
||||
)
|
||||
),
|
||||
O.getOrElseW(() => ({ authActive: false, authType: "none" }))
|
||||
) as HoppRESTAuth
|
||||
} else if (headers?.apikey || headers["api-key"]) {
|
||||
const apikey = headers?.apikey || headers["api-key"]
|
||||
if (apikey)
|
||||
auth = {
|
||||
authActive: true,
|
||||
authType: "api-key",
|
||||
key: headers?.apikey ? "apikey" : "api-key",
|
||||
value: apikey,
|
||||
addTo: "headers",
|
||||
}
|
||||
} else {
|
||||
if (parsedArguments.u) {
|
||||
const user: string = parsedArguments.u ?? ""
|
||||
;[username, password] = user.split(":")
|
||||
} else if (urlObject) {
|
||||
username = urlObject.username
|
||||
password = urlObject.password
|
||||
}
|
||||
|
||||
if (!!username && !!password)
|
||||
auth = {
|
||||
authType: "basic",
|
||||
authActive: true,
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
export function requestToHoppRequest(parsedCurl: CurlParserRequest) {
|
||||
const endpoint = parsedCurl.urlString
|
||||
const params = parsedCurl.queries || []
|
||||
const body = parsedCurl.body
|
||||
|
||||
const method = parsedCurl.method?.toUpperCase() || "GET"
|
||||
const contentType = parsedCurl.contentType
|
||||
const auth = parsedCurl.auth
|
||||
const headers =
|
||||
parsedCurl.hoppHeaders.filter(
|
||||
(header) =>
|
||||
header.key !== "Authorization" &&
|
||||
header.key !== "apikey" &&
|
||||
header.key !== "api-key"
|
||||
) || []
|
||||
|
||||
let finalBody: HoppRESTReqBody = {
|
||||
contentType: null,
|
||||
body: null,
|
||||
}
|
||||
|
||||
if (
|
||||
contentType &&
|
||||
contentType !== "multipart/form-data" &&
|
||||
typeof body === "string"
|
||||
)
|
||||
// final body if multipart data is not present
|
||||
finalBody = {
|
||||
contentType,
|
||||
body,
|
||||
}
|
||||
else if (Object.keys(parsedCurl.multipartUploads).length > 0) {
|
||||
// if multipart data is present
|
||||
const ydob: FormDataKeyValue[] = []
|
||||
for (const key in parsedCurl.multipartUploads) {
|
||||
ydob.push({
|
||||
active: true,
|
||||
isFile: false,
|
||||
key,
|
||||
value: parsedCurl.multipartUploads[key],
|
||||
})
|
||||
}
|
||||
finalBody = {
|
||||
contentType: "multipart/form-data",
|
||||
body: ydob,
|
||||
}
|
||||
}
|
||||
|
||||
return makeRESTRequest({
|
||||
name: "Untitled request",
|
||||
endpoint,
|
||||
method,
|
||||
params,
|
||||
headers,
|
||||
preRequestScript: "",
|
||||
testScript: "",
|
||||
auth,
|
||||
body: finalBody,
|
||||
})
|
||||
}
|
||||
29
packages/hoppscotch-app/helpers/curl/index.ts
Normal file
29
packages/hoppscotch-app/helpers/curl/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
HoppRESTReqBody,
|
||||
HoppRESTHeader,
|
||||
HoppRESTParam,
|
||||
HoppRESTAuth,
|
||||
} from "@hoppscotch/data"
|
||||
import { flow } from "fp-ts/function"
|
||||
import { parseCurlCommand, requestToHoppRequest } from "./curlparser"
|
||||
|
||||
export type CurlParserRequest = {
|
||||
urlString: string
|
||||
urlObject: URL | undefined
|
||||
compressed: boolean
|
||||
queries: HoppRESTParam[]
|
||||
hoppHeaders: HoppRESTHeader[]
|
||||
method: string
|
||||
contentType: HoppRESTReqBody["contentType"]
|
||||
body: HoppRESTReqBody["body"]
|
||||
cookies: Record<string, string> | undefined
|
||||
cookieString: string
|
||||
multipartUploads: Record<string, string>
|
||||
isDataBinary: boolean
|
||||
auth: HoppRESTAuth
|
||||
}
|
||||
|
||||
export const parseCurlToHoppRESTReq = flow(
|
||||
parseCurlCommand,
|
||||
requestToHoppRequest
|
||||
)
|
||||
@@ -1,264 +0,0 @@
|
||||
import * as URL from "url"
|
||||
import * as querystring from "querystring"
|
||||
import * as cookie from "cookie"
|
||||
import parser from "yargs-parser"
|
||||
|
||||
/**
|
||||
* given this: [ 'msg1=value1', 'msg2=value2' ]
|
||||
* output this: 'msg1=value1&msg2=value2'
|
||||
* @param dataArguments
|
||||
*/
|
||||
const joinDataArguments = (dataArguments: string[]) => {
|
||||
let data = ""
|
||||
dataArguments.forEach((argument, i) => {
|
||||
if (i === 0) {
|
||||
data += argument
|
||||
} else {
|
||||
data += `&${argument}`
|
||||
}
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
const parseDataFromArguments = (parsedArguments: any) => {
|
||||
if (parsedArguments.data) {
|
||||
return {
|
||||
data: Array.isArray(parsedArguments.data)
|
||||
? joinDataArguments(parsedArguments.data)
|
||||
: parsedArguments.data,
|
||||
dataArray: Array.isArray(parsedArguments.data)
|
||||
? parsedArguments.data
|
||||
: null,
|
||||
isDataBinary: false,
|
||||
}
|
||||
} else if (parsedArguments["data-binary"]) {
|
||||
return {
|
||||
data: Array.isArray(parsedArguments["data-binary"])
|
||||
? joinDataArguments(parsedArguments["data-binary"])
|
||||
: parsedArguments["data-binary"],
|
||||
dataArray: Array.isArray(parsedArguments["data-binary"])
|
||||
? parsedArguments["data-binary"]
|
||||
: null,
|
||||
isDataBinary: true,
|
||||
}
|
||||
} else if (parsedArguments.d) {
|
||||
return {
|
||||
data: Array.isArray(parsedArguments.d)
|
||||
? joinDataArguments(parsedArguments.d)
|
||||
: parsedArguments.d,
|
||||
dataArray: Array.isArray(parsedArguments.d) ? parsedArguments.d : null,
|
||||
isDataBinary: false,
|
||||
}
|
||||
} else if (parsedArguments["data-ascii"]) {
|
||||
return {
|
||||
data: Array.isArray(parsedArguments["data-ascii"])
|
||||
? joinDataArguments(parsedArguments["data-ascii"])
|
||||
: parsedArguments["data-ascii"],
|
||||
dataArray: Array.isArray(parsedArguments["data-ascii"])
|
||||
? parsedArguments["data-ascii"]
|
||||
: null,
|
||||
isDataBinary: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseCurlCommand = (curlCommand: string) => {
|
||||
const newlineFound = /\\/gi.test(curlCommand)
|
||||
if (newlineFound) {
|
||||
// remove '\' and newlines
|
||||
curlCommand = curlCommand.replace(/\\/gi, "")
|
||||
curlCommand = curlCommand.replace(/\n/g, "")
|
||||
}
|
||||
// replace string for insomnia
|
||||
curlCommand = curlCommand.replace(/--request /, "-X ")
|
||||
curlCommand = curlCommand.replace(/--header /, "-H ")
|
||||
curlCommand = curlCommand.replace(/--url /, " ")
|
||||
curlCommand = curlCommand.replace(/-d /, "--data ")
|
||||
|
||||
// yargs parses -XPOST as separate arguments. just prescreen for it.
|
||||
curlCommand = curlCommand.replace(/ -XPOST/, " -X POST")
|
||||
curlCommand = curlCommand.replace(/ -XGET/, " -X GET")
|
||||
curlCommand = curlCommand.replace(/ -XPUT/, " -X PUT")
|
||||
curlCommand = curlCommand.replace(/ -XPATCH/, " -X PATCH")
|
||||
curlCommand = curlCommand.replace(/ -XDELETE/, " -X DELETE")
|
||||
curlCommand = curlCommand.trim()
|
||||
const parsedArguments = parser(curlCommand)
|
||||
|
||||
const rawData =
|
||||
parsedArguments.data ||
|
||||
parsedArguments.dataRaw ||
|
||||
parsedArguments["data-raw"]
|
||||
|
||||
let cookieString
|
||||
let cookies
|
||||
let url = parsedArguments._[1]
|
||||
if (!url) {
|
||||
for (const argName in parsedArguments) {
|
||||
if (typeof parsedArguments[argName] === "string") {
|
||||
if (["http", "www."].includes(parsedArguments[argName])) {
|
||||
url = parsedArguments[argName]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let headers: any
|
||||
|
||||
const parseHeaders = (headerFieldName: string) => {
|
||||
if (parsedArguments[headerFieldName]) {
|
||||
if (!headers) {
|
||||
headers = {}
|
||||
}
|
||||
if (!Array.isArray(parsedArguments[headerFieldName])) {
|
||||
parsedArguments[headerFieldName] = [parsedArguments[headerFieldName]]
|
||||
}
|
||||
parsedArguments[headerFieldName].forEach((header: string) => {
|
||||
if (header.includes("Cookie")) {
|
||||
// stupid javascript tricks: closure
|
||||
cookieString = header
|
||||
} else {
|
||||
const colonIndex = header.indexOf(":")
|
||||
const headerName = header.substring(0, colonIndex)
|
||||
const headerValue = header.substring(colonIndex + 1).trim()
|
||||
headers[headerName] = headerValue
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
parseHeaders("H")
|
||||
parseHeaders("header")
|
||||
if (parsedArguments.A) {
|
||||
if (!headers) {
|
||||
headers = []
|
||||
}
|
||||
headers["User-Agent"] = parsedArguments.A
|
||||
} else if (parsedArguments["user-agent"]) {
|
||||
if (!headers) {
|
||||
headers = []
|
||||
}
|
||||
headers["User-Agent"] = parsedArguments["user-agent"]
|
||||
}
|
||||
|
||||
if (parsedArguments.b) {
|
||||
cookieString = parsedArguments.b
|
||||
}
|
||||
if (parsedArguments.cookie) {
|
||||
cookieString = parsedArguments.cookie
|
||||
}
|
||||
const multipartUploads: Record<string, string> = {}
|
||||
if (parsedArguments.F) {
|
||||
if (!Array.isArray(parsedArguments.F)) {
|
||||
parsedArguments.F = [parsedArguments.F]
|
||||
}
|
||||
parsedArguments.F.forEach((multipartArgument: string) => {
|
||||
// input looks like key=value. value could be json or a file path prepended with an @
|
||||
const [key, value] = multipartArgument.split("=", 2)
|
||||
multipartUploads[key] = value
|
||||
})
|
||||
}
|
||||
if (cookieString) {
|
||||
const cookieParseOptions = {
|
||||
decode: (s: any) => s,
|
||||
}
|
||||
// separate out cookie headers into separate data structure
|
||||
// note: cookie is case insensitive
|
||||
cookies = cookie.parse(
|
||||
cookieString.replace(/^Cookie: /gi, ""),
|
||||
cookieParseOptions
|
||||
)
|
||||
}
|
||||
let method
|
||||
if (parsedArguments.X === "POST") {
|
||||
method = "post"
|
||||
} else if (parsedArguments.X === "PUT" || parsedArguments.T) {
|
||||
method = "put"
|
||||
} else if (parsedArguments.X === "PATCH") {
|
||||
method = "patch"
|
||||
} else if (parsedArguments.X === "DELETE") {
|
||||
method = "delete"
|
||||
} else if (parsedArguments.X === "OPTIONS") {
|
||||
method = "options"
|
||||
} else if (
|
||||
(parsedArguments.d ||
|
||||
parsedArguments.data ||
|
||||
parsedArguments["data-ascii"] ||
|
||||
parsedArguments["data-binary"] ||
|
||||
parsedArguments.F ||
|
||||
parsedArguments.form) &&
|
||||
!(parsedArguments.G || parsedArguments.get)
|
||||
) {
|
||||
method = "post"
|
||||
} else if (parsedArguments.I || parsedArguments.head) {
|
||||
method = "head"
|
||||
} else {
|
||||
method = "get"
|
||||
}
|
||||
|
||||
let body = ""
|
||||
|
||||
if (rawData) {
|
||||
try {
|
||||
const tempBody = JSON.parse(rawData)
|
||||
if (tempBody) {
|
||||
body = JSON.stringify(tempBody, null, 2)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"Error parsing JSON data. Please ensure that the data is valid JSON."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const compressed = !!parsedArguments.compressed
|
||||
let urlObject = URL.parse(url) // eslint-disable-line
|
||||
|
||||
// if GET request with data, convert data to query string
|
||||
// NB: the -G flag does not change the http verb. It just moves the data into the url.
|
||||
if (parsedArguments.G || parsedArguments.get) {
|
||||
urlObject.query = urlObject.query ? urlObject.query : ""
|
||||
const option =
|
||||
"d" in parsedArguments ? "d" : "data" in parsedArguments ? "data" : null
|
||||
if (option) {
|
||||
let urlQueryString = ""
|
||||
|
||||
if (!url.includes("?")) {
|
||||
url += "?"
|
||||
} else {
|
||||
urlQueryString += "&"
|
||||
}
|
||||
|
||||
if (typeof parsedArguments[option] === "object") {
|
||||
urlQueryString += parsedArguments[option].join("&")
|
||||
} else {
|
||||
urlQueryString += parsedArguments[option]
|
||||
}
|
||||
urlObject.query += urlQueryString
|
||||
url += urlQueryString
|
||||
delete parsedArguments[option]
|
||||
}
|
||||
}
|
||||
const query = querystring.parse(urlObject.query!, null as any, null as any, {
|
||||
maxKeys: 10000,
|
||||
})
|
||||
|
||||
urlObject.search = null // Clean out the search/query portion.
|
||||
const request = {
|
||||
url,
|
||||
urlWithoutQuery: URL.format(urlObject),
|
||||
compressed,
|
||||
query,
|
||||
headers,
|
||||
method,
|
||||
body,
|
||||
cookies,
|
||||
cookieString: cookieString?.replace("Cookie: ", ""),
|
||||
multipartUploads,
|
||||
...parseDataFromArguments(parsedArguments),
|
||||
auth: parsedArguments.u,
|
||||
user: parsedArguments.user,
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
export default parseCurlCommand
|
||||
@@ -7,3 +7,16 @@ export const trace = <T>(x: T) => {
|
||||
console.log(x)
|
||||
return x
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the annotated current value and returns the same value
|
||||
* @param name The name of the log
|
||||
* @curried_param `x` The value to log
|
||||
* @returns The parameter `x` passed to this
|
||||
*/
|
||||
export const namedTrace =
|
||||
(name: string) =>
|
||||
<T>(x: T) => {
|
||||
console.log(`${name}: `, x)
|
||||
return x
|
||||
}
|
||||
|
||||
9
packages/hoppscotch-app/helpers/functional/json.ts
Normal file
9
packages/hoppscotch-app/helpers/functional/json.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as O from "fp-ts/Option"
|
||||
|
||||
/**
|
||||
* Checks and Parses JSON string
|
||||
* @param str Raw JSON data to be parsed
|
||||
* @returns Option type with some(JSON data) or none
|
||||
*/
|
||||
export const safeParseJSON = (str: string): O.Option<object> =>
|
||||
O.tryCatch(() => JSON.parse(str))
|
||||
Reference in New Issue
Block a user