From 9a86d0c207406e6337e218011b35f7593db9c4fa Mon Sep 17 00:00:00 2001 From: Akash K <57758277+amk-dev@users.noreply.github.com> Date: Thu, 29 Aug 2024 14:33:07 +0530 Subject: [PATCH] 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> --- packages/hoppscotch-common/locales/en.json | 2 + .../components/collections/ImportExport.vue | 34 +++ .../src/helpers/import-export/import/har.ts | 250 ++++++++++++++++++ .../helpers/import-export/import/importers.ts | 1 + 4 files changed, 287 insertions(+) create mode 100644 packages/hoppscotch-common/src/helpers/import-export/import/har.ts diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 4ce8e8df8..49c002da5 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -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", diff --git a/packages/hoppscotch-common/src/components/collections/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/ImportExport.vue index bdf18d26e..7bc85c676 100644 --- a/packages/hoppscotch-common/src/components/collections/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/collections/ImportExport.vue @@ -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" diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/har.ts b/packages/hoppscotch-common/src/helpers/import-export/import/har.ts new file mode 100644 index 000000000..8eaf8175d --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/import-export/import/har.ts @@ -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 +): 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 diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/importers.ts b/packages/hoppscotch-common/src/helpers/import-export/import/importers.ts index 8f7ec3e55..e1aebf350 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/importers.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/importers.ts @@ -3,3 +3,4 @@ export { hoppOpenAPIImporter } from "./openapi" export { hoppPostmanImporter } from "./postman" export { hoppInsomniaImporter } from "./insomnia" export { toTeamsImporter } from "./myCollections" +export { harImporter } from "./har"