feat: support importing HAR ( Http Archive Format ) files (#4307)
* feat: import har files to hoppscotch * chore: fix some edge cases * chore: exclude query params from the generated request endpoint --------- Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
@@ -448,6 +448,8 @@
|
|||||||
"from_postman_description": "Import from Postman collection",
|
"from_postman_description": "Import from Postman collection",
|
||||||
"from_url": "Import from URL",
|
"from_url": "Import from URL",
|
||||||
"gist_url": "Enter Gist URL",
|
"gist_url": "Enter Gist URL",
|
||||||
|
"from_har": "Import from HAR",
|
||||||
|
"from_har_description": "Import from HAR file",
|
||||||
"gql_collections_from_gist_description": "Import GraphQL Collections From Gist",
|
"gql_collections_from_gist_description": "Import GraphQL Collections From Gist",
|
||||||
"hoppscotch_environment": "Hoppscotch Environment",
|
"hoppscotch_environment": "Hoppscotch Environment",
|
||||||
"hoppscotch_environment_description": "Import Hoppscotch Environment JSON file",
|
"hoppscotch_environment_description": "Import Hoppscotch Environment JSON file",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
hoppPostmanImporter,
|
hoppPostmanImporter,
|
||||||
toTeamsImporter,
|
toTeamsImporter,
|
||||||
hoppOpenAPIImporter,
|
hoppOpenAPIImporter,
|
||||||
|
harImporter,
|
||||||
} from "~/helpers/import-export/import/importers"
|
} from "~/helpers/import-export/import/importers"
|
||||||
|
|
||||||
import { defineStep } from "~/composables/step-components"
|
import { defineStep } from "~/composables/step-components"
|
||||||
@@ -505,6 +506,38 @@ const HoppGistCollectionsExporter: ImporterOrExporter = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HARImporter: ImporterOrExporter = {
|
||||||
|
metadata: {
|
||||||
|
id: "har",
|
||||||
|
name: "import.from_har",
|
||||||
|
title: "import.from_har_description",
|
||||||
|
icon: IconFile,
|
||||||
|
disabled: false,
|
||||||
|
applicableTo: ["personal-workspace", "team-workspace"],
|
||||||
|
},
|
||||||
|
component: FileSource({
|
||||||
|
caption: "import.from_file",
|
||||||
|
acceptedFileTypes: ".har",
|
||||||
|
onImportFromFile: async (content) => {
|
||||||
|
const res = await harImporter(content)
|
||||||
|
|
||||||
|
if (E.isLeft(res)) {
|
||||||
|
showImportFailedError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImportToStore(res.right)
|
||||||
|
|
||||||
|
platform.analytics?.logEvent({
|
||||||
|
type: "HOPP_IMPORT_COLLECTION",
|
||||||
|
importer: "import.from_har",
|
||||||
|
platform: "rest",
|
||||||
|
workspaceType: isTeamWorkspace.value ? "team" : "personal",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
const importerModules = computed(() => {
|
const importerModules = computed(() => {
|
||||||
const enabledImporters = [
|
const enabledImporters = [
|
||||||
HoppRESTImporter,
|
HoppRESTImporter,
|
||||||
@@ -513,6 +546,7 @@ const importerModules = computed(() => {
|
|||||||
HoppPostmanImporter,
|
HoppPostmanImporter,
|
||||||
HoppInsomniaImporter,
|
HoppInsomniaImporter,
|
||||||
HoppGistImporter,
|
HoppGistImporter,
|
||||||
|
HARImporter,
|
||||||
]
|
]
|
||||||
|
|
||||||
const isTeams = props.collectionsType.type === "team-collections"
|
const isTeams = props.collectionsType.type === "team-collections"
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import {
|
||||||
|
HoppCollection,
|
||||||
|
HoppRESTHeader,
|
||||||
|
HoppRESTParam,
|
||||||
|
ValidContentTypesList,
|
||||||
|
ValidContentTypes,
|
||||||
|
HoppRESTReqBody,
|
||||||
|
HoppRESTReqBodyFormData,
|
||||||
|
HoppRESTRequest,
|
||||||
|
getDefaultRESTRequest,
|
||||||
|
makeCollection,
|
||||||
|
} from "@hoppscotch/data"
|
||||||
|
import * as E from "fp-ts/Either"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export const harImporter = (
|
||||||
|
content: string[]
|
||||||
|
): E.Either<"INVALID_HAR" | "SOMETHING_WENT_WRONG", HoppCollection[]> => {
|
||||||
|
try {
|
||||||
|
const harObject = JSON.parse(content[0])
|
||||||
|
const res = harSchema.safeParse(harObject)
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
return E.left("INVALID_HAR")
|
||||||
|
}
|
||||||
|
|
||||||
|
const har = res.data
|
||||||
|
|
||||||
|
const requests = harToHoppscotchRequestConverter(har)
|
||||||
|
|
||||||
|
const collection = makeCollection({
|
||||||
|
name: "Imported from HAR",
|
||||||
|
folders: [],
|
||||||
|
requests: requests,
|
||||||
|
auth: {
|
||||||
|
authType: "none",
|
||||||
|
authActive: true,
|
||||||
|
},
|
||||||
|
headers: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return E.right([collection])
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
return E.left("SOMETHING_WENT_WRONG")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const harToHoppscotchRequestConverter = (har: HAR) => {
|
||||||
|
return har.log.entries.map((entry): HoppRESTRequest => {
|
||||||
|
const headers = entry.request.headers.map((header): HoppRESTHeader => {
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
key: header.name,
|
||||||
|
value: header.value,
|
||||||
|
description: "",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const params = entry.request.queryString.map((param): HoppRESTParam => {
|
||||||
|
return {
|
||||||
|
active: true,
|
||||||
|
key: param.name,
|
||||||
|
value: param.value,
|
||||||
|
description: "",
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = entry.request.postData
|
||||||
|
? convertPostDataToHoppBody(entry.request.postData)
|
||||||
|
: { body: null, contentType: null }
|
||||||
|
|
||||||
|
const { method, url } = entry.request
|
||||||
|
|
||||||
|
const parsedUrl = new URL(url)
|
||||||
|
const urlWithoutQueryParams = parsedUrl.origin + parsedUrl.pathname
|
||||||
|
|
||||||
|
return {
|
||||||
|
...getDefaultRESTRequest(),
|
||||||
|
endpoint: urlWithoutQueryParams,
|
||||||
|
params,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
method,
|
||||||
|
name: url,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertPostDataToHoppBody = (
|
||||||
|
postData: z.infer<typeof postDataSchema>
|
||||||
|
): HoppRESTReqBody => {
|
||||||
|
let contentType: ValidContentTypes = "text/plain"
|
||||||
|
|
||||||
|
if (isValidContentType(postData.mimeType)) {
|
||||||
|
contentType = postData.mimeType
|
||||||
|
} else if (postData.mimeType.startsWith("multipart/form-data")) {
|
||||||
|
// some har files will have formdata formatted like multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
|
||||||
|
contentType = "multipart/form-data"
|
||||||
|
}
|
||||||
|
|
||||||
|
// all the contentTypes except application/x-www-form-urlencoded && multipart/form-data will have text content
|
||||||
|
if (
|
||||||
|
contentType === "application/json" ||
|
||||||
|
contentType === "application/hal+json" ||
|
||||||
|
contentType === "application/ld+json" ||
|
||||||
|
contentType === "application/vnd.api+json" ||
|
||||||
|
contentType === "application/xml" ||
|
||||||
|
contentType === "text/html" ||
|
||||||
|
contentType === "text/xml" ||
|
||||||
|
contentType === "text/plain"
|
||||||
|
) {
|
||||||
|
const body: HoppRESTReqBody = {
|
||||||
|
body: postData.text ?? "",
|
||||||
|
contentType,
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType === "application/x-www-form-urlencoded") {
|
||||||
|
let bodyContent: string = ""
|
||||||
|
|
||||||
|
if (postData.text) {
|
||||||
|
bodyContent = formatXWWWFormUrlencodedForHoppscotch(postData.text)
|
||||||
|
} else if (postData.params) {
|
||||||
|
bodyContent = postData.params
|
||||||
|
.map((param) => {
|
||||||
|
return `${param.name}:${param.value}`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: HoppRESTReqBody = {
|
||||||
|
contentType: "application/x-www-form-urlencoded",
|
||||||
|
body: bodyContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType === "multipart/form-data") {
|
||||||
|
const body: HoppRESTReqBodyFormData = {
|
||||||
|
contentType: "multipart/form-data",
|
||||||
|
body:
|
||||||
|
postData.params?.map((param) => {
|
||||||
|
return param.fileName
|
||||||
|
? {
|
||||||
|
active: true,
|
||||||
|
isFile: true as const,
|
||||||
|
key: param.name,
|
||||||
|
value: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
active: true,
|
||||||
|
isFile: false as const,
|
||||||
|
key: param.name,
|
||||||
|
value: param.value ?? "",
|
||||||
|
}
|
||||||
|
}) ?? [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: "",
|
||||||
|
contentType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidContentType = (
|
||||||
|
contentType: string
|
||||||
|
): contentType is ValidContentTypes =>
|
||||||
|
(ValidContentTypesList as string[]).includes(contentType)
|
||||||
|
|
||||||
|
const formatXWWWFormUrlencodedForHoppscotch = (text: string) => {
|
||||||
|
const params = new URLSearchParams(text)
|
||||||
|
const result = []
|
||||||
|
|
||||||
|
for (const [key, value] of params) {
|
||||||
|
result.push(`${key}:${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// <------har zod schema defs------>
|
||||||
|
// we only define parts of the schema that we need
|
||||||
|
|
||||||
|
const recordSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cookieSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
domain: z.string().optional(),
|
||||||
|
expires: z.string().optional(),
|
||||||
|
httpOnly: z.boolean().optional(),
|
||||||
|
secure: z.boolean().optional(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const postDataSchema = z.object({
|
||||||
|
mimeType: z.string(),
|
||||||
|
text: z.string().optional(),
|
||||||
|
params: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
value: z.string().optional(),
|
||||||
|
fileName: z.string().optional(),
|
||||||
|
contentType: z.string().optional(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
method: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
httpVersion: z.string(),
|
||||||
|
cookies: z.array(cookieSchema),
|
||||||
|
headers: z.array(recordSchema),
|
||||||
|
queryString: z.array(recordSchema),
|
||||||
|
postData: postDataSchema.optional(),
|
||||||
|
headersSize: z.number().int(),
|
||||||
|
bodySize: z.number().int(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const entrySchema = z.object({
|
||||||
|
request: requestSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
const logSchema = z.object({
|
||||||
|
entries: z.array(entrySchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
const harSchema = z.object({
|
||||||
|
log: logSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
type HAR = z.infer<typeof harSchema>
|
||||||
@@ -3,3 +3,4 @@ export { hoppOpenAPIImporter } from "./openapi"
|
|||||||
export { hoppPostmanImporter } from "./postman"
|
export { hoppPostmanImporter } from "./postman"
|
||||||
export { hoppInsomniaImporter } from "./insomnia"
|
export { hoppInsomniaImporter } from "./insomnia"
|
||||||
export { toTeamsImporter } from "./myCollections"
|
export { toTeamsImporter } from "./myCollections"
|
||||||
|
export { harImporter } from "./har"
|
||||||
|
|||||||
Reference in New Issue
Block a user