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:
Akash K
2024-08-29 14:33:07 +05:30
committed by GitHub
parent 55c1c31b73
commit 9a86d0c207
4 changed files with 287 additions and 0 deletions

View File

@@ -448,6 +448,8 @@
"from_postman_description": "Import from Postman collection",
"from_url": "Import from 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",
"hoppscotch_environment": "Hoppscotch Environment",
"hoppscotch_environment_description": "Import Hoppscotch Environment JSON file",

View File

@@ -24,6 +24,7 @@ import {
hoppPostmanImporter,
toTeamsImporter,
hoppOpenAPIImporter,
harImporter,
} from "~/helpers/import-export/import/importers"
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 enabledImporters = [
HoppRESTImporter,
@@ -513,6 +546,7 @@ const importerModules = computed(() => {
HoppPostmanImporter,
HoppInsomniaImporter,
HoppGistImporter,
HARImporter,
]
const isTeams = props.collectionsType.type === "team-collections"

View File

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

View File

@@ -3,3 +3,4 @@ export { hoppOpenAPIImporter } from "./openapi"
export { hoppPostmanImporter } from "./postman"
export { hoppInsomniaImporter } from "./insomnia"
export { toTeamsImporter } from "./myCollections"
export { harImporter } from "./har"