feat: support for binary body (#4466)

Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Akash K
2024-11-26 19:48:01 +05:30
committed by GitHub
parent 37bf0567ea
commit 80d7dd046d
16 changed files with 261 additions and 22 deletions

View File

@@ -43,5 +43,5 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
active: boolean; active: boolean;
description: string; description: string;
}[]; }[];
effectiveFinalBody: FormData | string | null; effectiveFinalBody: FormData | string | File | null;
} }

View File

@@ -360,7 +360,7 @@ export async function getEffectiveRESTRequest(
function getFinalBodyFromRequest( function getFinalBodyFromRequest(
request: HoppRESTRequest, request: HoppRESTRequest,
resolvedVariables: EnvironmentVariable[] resolvedVariables: EnvironmentVariable[]
): E.Either<HoppCLIError, string | null | FormData> { ): E.Either<HoppCLIError, string | null | FormData | File> {
if (request.body.contentType === null) { if (request.body.contentType === null) {
return E.right(null); return E.right(null);
} }
@@ -437,6 +437,20 @@ function getFinalBodyFromRequest(
); );
} }
if (request.body.contentType === "application/octet-stream") {
const body = request.body.body;
if (!body) {
return E.right(null);
}
if (!(body instanceof File)) {
return E.right(null);
}
return E.right(body);
}
return pipe( return pipe(
parseBodyEnvVariablesE(request.body.body, resolvedVariables), parseBodyEnvVariablesE(request.body.body, resolvedVariables),
E.mapLeft((e) => E.mapLeft((e) =>

View File

@@ -574,6 +574,9 @@
"extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.",
"extention_enable_action": "Enable Browser Extension", "extention_enable_action": "Enable Browser Extension",
"extention_not_enabled": "Extension not enabled." "extention_not_enabled": "Extension not enabled."
},
"requestBody": {
"agent_doesnt_support_binary_body": "Sending binary data via agent is not supported yet"
} }
}, },
"layout": { "layout": {
@@ -666,7 +669,8 @@
"content_type_titles": { "content_type_titles": {
"others": "Others", "others": "Others",
"structured": "Structured", "structured": "Structured",
"text": "Text" "text": "Text",
"binary": "Binary"
}, },
"show_content_type": "Show Content Type", "show_content_type": "Show Content Type",
"different_collection": "Cannot reorder requests from different collections", "different_collection": "Cannot reorder requests from different collections",

View File

@@ -42,6 +42,7 @@
> >
{{ inspector.text.text }} {{ inspector.text.text }}
<HoppSmartLink <HoppSmartLink
v-if="inspector.doc"
blank blank
:to="inspector.doc.link" :to="inspector.doc.link"
class="text-accent transition hover:text-accentDark" class="text-accent transition hover:text-accentDark"

View File

@@ -35,6 +35,7 @@
@click=" @click="
() => { () => {
body.contentType = null body.contentType = null
body.body = null
hide() hide()
} }
" "
@@ -76,6 +77,7 @@
</div> </div>
</template> </template>
</tippy> </tippy>
<AppInspection :inspection-results="tabResults" />
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }" v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="t('request.override_help')" :title="t('request.override_help')"
@@ -107,6 +109,10 @@
v-model="body" v-model="body"
:envs="envs" :envs="envs"
/> />
<HttpBodyBinary
v-else-if="body.contentType === 'application/octet-stream'"
v-model="body"
/>
<HttpRawBody v-else-if="body.contentType !== null" v-model="body" /> <HttpRawBody v-else-if="body.contentType !== null" v-model="body" />
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-if="body.contentType == null" v-if="body.contentType == null"
@@ -144,6 +150,9 @@ import IconInfo from "~icons/lucide/info"
import IconRefreshCW from "~icons/lucide/refresh-cw" import IconRefreshCW from "~icons/lucide/refresh-cw"
import { RESTOptionTabs } from "./RequestOptions.vue" import { RESTOptionTabs } from "./RequestOptions.vue"
import { AggregateEnvironment } from "~/newstore/environments" import { AggregateEnvironment } from "~/newstore/environments"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { InspectionService } from "~/services/inspection"
const colorMode = useColorMode() const colorMode = useColorMode()
const t = useI18n() const t = useI18n()
@@ -195,4 +204,12 @@ const isContentTypeAlreadyExist = () => {
// Template refs // Template refs
const tippyActions = ref<any | null>(null) const tippyActions = ref<any | null>(null)
const tabs = useService(RESTTabService)
const inspectionService = useService(InspectionService)
const tabResults = inspectionService.getResultViewFor(
tabs.currentTabID.value,
(result) => result.locations.type === "body-content-type-header"
)
</script> </script>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { watch } from "vue"
type BinaryBody = {
contentType: "application/octet-stream"
body: File | null
}
const props = defineProps<{
modelValue: BinaryBody
}>()
const emit = defineEmits<{
(e: "update:modelValue", value: BinaryBody): void
}>()
// in the parent component,
// there's a invalid assignment happening
// when switching between different body types only the content type is reset, not the body
// need to look into this
// eg: body: some-json-value-user-entered, contentType: "application/json" -> change content type-> body: some-json-value-user-entered, contentType: "application/octet-stream"
// this is not caught by the type system
// but this behavior right now gives us persistance, which will prevent unwanted data loss
// eg: when the user comes back to the json body, the value is still there
// so to solve this, we need to consider this too.
watch(
props.modelValue,
(val) => {
if (!(val.body instanceof File)) {
emit("update:modelValue", {
body: null,
contentType: "application/octet-stream",
})
}
},
{
immediate: true,
}
)
const handleFileChange = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
emit("update:modelValue", {
body: file,
contentType: "application/octet-stream",
})
} else {
emit("update:modelValue", {
body: null,
contentType: "application/octet-stream",
})
}
}
</script>
<template>
<span>
<label :for="`attachment-binary-body`" class="p-0">
<input
:id="`attachment-binary-body`"
:name="`attachment-binary-body`"
type="file"
class="cursor-pointer p-1 text-tiny text-secondaryLight transition file:mr-2 file:cursor-pointer file:rounded file:border-0 file:bg-primaryLight file:px-4 file:py-1 file:text-tiny file:text-secondary file:transition hover:text-secondaryDark hover:file:bg-primaryDark hover:file:text-secondaryDark"
@change="handleFileChange"
/>
</label>
</span>
</template>

View File

@@ -259,7 +259,7 @@ import { flow, pipe } from "fp-ts/function"
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import * as RA from "fp-ts/ReadonlyArray" import * as RA from "fp-ts/ReadonlyArray"
import { cloneDeep, isEqual } from "lodash-es" import { cloneDeep, isEqual } from "lodash-es"
import { reactive, ref, toRef, watch } from "vue" import { reactive, Ref, ref, toRef, watch } from "vue"
import draggable from "vuedraggable-es" import draggable from "vuedraggable-es"
import { computedAsync, useVModel } from "@vueuse/core" import { computedAsync, useVModel } from "@vueuse/core"
@@ -546,16 +546,22 @@ const clearContent = () => {
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs()) const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs())
const computedHeaders = computedAsync( const computedHeaders: Ref<
async () => {
(await getComputedHeaders(request.value, aggregateEnvs.value, false)).map( source: "auth" | "body"
(header, index) => ({ header: HoppRESTHeader
id: `header-${index}`, id: string
...header, }[]
}) > = ref([])
),
[] watch([props.modelValue, aggregateEnvs], async () => {
) computedHeaders.value = (
await getComputedHeaders(props.modelValue, aggregateEnvs.value, false)
).map((header, index) => ({
id: `header-${index}`,
...header,
}))
})
const inheritedProperties = computedAsync(async () => { const inheritedProperties = computedAsync(async () => {
if (!props.inheritedProperties?.auth || !props.inheritedProperties.headers) if (!props.inheritedProperties?.auth || !props.inheritedProperties.headers)
@@ -671,7 +677,11 @@ const headerValueResults = inspectionService.getResultViewFor(
const getInspectorResult = (results: InspectorResult[], index: number) => { const getInspectorResult = (results: InspectorResult[], index: number) => {
return results.filter((result) => { return results.filter((result) => {
if (result.locations.type === "url" || result.locations.type === "response") if (
result.locations.type === "url" ||
result.locations.type === "response" ||
result.locations.type === "body-content-type-header"
)
return return
return result.locations.index === index return result.locations.index === index
}) })

View File

@@ -379,7 +379,11 @@ const parameterValueResults = inspectionService.getResultViewFor(
const getInspectorResult = (results: InspectorResult[], index: number) => { const getInspectorResult = (results: InspectorResult[], index: number) => {
return results.filter((result) => { return results.filter((result) => {
if (result.locations.type === "url" || result.locations.type === "response") if (
result.locations.type === "url" ||
result.locations.type === "response" ||
result.locations.type === "body-content-type-header"
)
return return
return result.locations.index === index return result.locations.index === index
}) })

View File

@@ -44,7 +44,7 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
effectiveFinalURL: string effectiveFinalURL: string
effectiveFinalHeaders: HoppRESTHeaders effectiveFinalHeaders: HoppRESTHeaders
effectiveFinalParams: HoppRESTParams effectiveFinalParams: HoppRESTParams
effectiveFinalBody: FormData | string | null effectiveFinalBody: FormData | string | null | File
effectiveFinalRequestVariables: { key: string; value: string }[] effectiveFinalRequestVariables: { key: string; value: string }[]
} }
@@ -249,6 +249,32 @@ export const getComputedBodyHeaders = (
// Body should have a non-null content-type // Body should have a non-null content-type
if (!req.body || req.body.contentType === null) return [] if (!req.body || req.body.contentType === null) return []
if (
req.body &&
req.body.contentType === "application/octet-stream" &&
req.body.body
) {
const filename = req.body.body.name
const fileType = req.body.body.type
const contentType = fileType ? fileType : "application/octet-stream"
return [
{
active: true,
key: "content-type",
value: contentType,
description: "",
},
{
active: true,
key: "Content-Disposition",
value: `attachment; filename="${filename}"`,
description: "",
},
]
}
return [ return [
{ {
active: true, active: true,
@@ -408,6 +434,10 @@ export const resolvesEnvsInBody = (
): HoppRESTReqBody => { ): HoppRESTReqBody => {
if (!body.contentType) return body if (!body.contentType) return body
if (body.contentType === "application/octet-stream") {
return body
}
if (body.contentType === "multipart/form-data") { if (body.contentType === "multipart/form-data") {
if (!body.body) { if (!body.body) {
return { return {
@@ -448,7 +478,7 @@ function getFinalBodyFromRequest(
request: HoppRESTRequest, request: HoppRESTRequest,
envVariables: Environment["variables"], envVariables: Environment["variables"],
showKeyIfSecret = false showKeyIfSecret = false
): FormData | string | null { ): FormData | Blob | string | null {
if (request.body.contentType === null) return null if (request.body.contentType === null) return null
if (request.body.contentType === "application/x-www-form-urlencoded") { if (request.body.contentType === "application/x-www-form-urlencoded") {
@@ -527,6 +557,10 @@ function getFinalBodyFromRequest(
) )
} }
if (request.body.contentType === "application/octet-stream") {
return request.body.body
}
let bodyContent = request.body.body ?? "" let bodyContent = request.body.body ?? ""
if (isJSONContentType(request.body.contentType)) if (isJSONContentType(request.body.contentType))

View File

@@ -1,6 +1,6 @@
import { ValidContentTypes } from "@hoppscotch/data" import { ValidContentTypes } from "@hoppscotch/data"
export type Content = "json" | "xml" | "multipart" | "html" | "plain" export type Content = "json" | "xml" | "multipart" | "html" | "plain" | "binary"
export const knownContentTypes: Record<ValidContentTypes, Content> = { export const knownContentTypes: Record<ValidContentTypes, Content> = {
"application/json": "json", "application/json": "json",
@@ -10,6 +10,7 @@ export const knownContentTypes: Record<ValidContentTypes, Content> = {
"application/xml": "xml", "application/xml": "xml",
"application/x-www-form-urlencoded": "multipart", "application/x-www-form-urlencoded": "multipart",
"multipart/form-data": "multipart", "multipart/form-data": "multipart",
"application/octet-stream": "binary",
"text/html": "html", "text/html": "html",
"text/plain": "plain", "text/plain": "plain",
"text/xml": "xml", "text/xml": "xml",
@@ -19,6 +20,7 @@ type ContentTypeTitle =
| "request.content_type_titles.text" | "request.content_type_titles.text"
| "request.content_type_titles.structured" | "request.content_type_titles.structured"
| "request.content_type_titles.others" | "request.content_type_titles.others"
| "request.content_type_titles.binary"
type SegmentedContentType = { type SegmentedContentType = {
title: ContentTypeTitle title: ContentTypeTitle
@@ -41,6 +43,10 @@ export const segmentedContentTypes: SegmentedContentType[] = [
title: "request.content_type_titles.structured", title: "request.content_type_titles.structured",
contentTypes: ["application/x-www-form-urlencoded", "multipart/form-data"], contentTypes: ["application/x-www-form-urlencoded", "multipart/form-data"],
}, },
{
title: "request.content_type_titles.binary",
contentTypes: ["application/octet-stream"],
},
{ {
title: "request.content_type_titles.others", title: "request.content_type_titles.others",
contentTypes: ["text/html", "text/plain"], contentTypes: ["text/html", "text/plain"],

View File

@@ -146,6 +146,7 @@ import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection" import { InspectionService } from "~/services/inspection"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector" import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector" import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { InterceptorsInspectorService } from "~/services/inspection/inspectors/interceptors.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector" import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
@@ -417,6 +418,8 @@ useService(HeaderInspectorService)
useService(EnvironmentInspectorService) useService(EnvironmentInspectorService)
useService(ResponseInspectorService) useService(ResponseInspectorService)
useService(AuthorizationInspectorService) useService(AuthorizationInspectorService)
useService(InterceptorsInspectorService)
for (const inspectorDef of platform.additionalInspectors ?? []) { for (const inspectorDef of platform.additionalInspectors ?? []) {
useService(inspectorDef.service) useService(inspectorDef.service)
} }

View File

@@ -46,6 +46,9 @@ export type InspectorLocation =
| { | {
type: "response" type: "response"
} }
| {
type: "body-content-type-header"
}
/** /**
* Defines info about an inspector result so the UI can render it * Defines info about an inspector result so the UI can render it
@@ -60,7 +63,7 @@ export interface InspectorResult {
text: string text: string
apply: () => void apply: () => void
} }
doc: { doc?: {
text: string text: string
link: string link: string
} }

View File

@@ -100,7 +100,8 @@ export class EnvironmentInspectorService extends Service implements Inspector {
position: position:
locations.type === "url" || locations.type === "url" ||
locations.type === "body" || locations.type === "body" ||
locations.type === "response" locations.type === "response" ||
locations.type === "body-content-type-header"
? "key" ? "key"
: locations.position, : locations.position,
index: index, index: index,
@@ -222,7 +223,8 @@ export class EnvironmentInspectorService extends Service implements Inspector {
position: position:
locations.type === "url" || locations.type === "url" ||
locations.type === "body" || locations.type === "body" ||
locations.type === "response" locations.type === "response" ||
locations.type === "body-content-type-header"
? "key" ? "key"
: locations.position, : locations.position,
index: index, index: index,

View File

@@ -0,0 +1,65 @@
import { Service } from "dioc"
import { InspectionService, Inspector, InspectorResult } from ".."
import { computed, Ref } from "vue"
import {
HoppRESTRequest,
HoppRESTResponseOriginalRequest,
} from "@hoppscotch/data"
import IconAlertCircle from "~icons/lucide/alert-circle"
import { InterceptorService } from "~/services/interceptor.service"
import { getI18n } from "~/modules/i18n"
/**
* This inspector is responsible for inspecting the interceptor usage.
*
* NOTE: Initializing this service registers it as a inspector with the Inspection Service.
*/
export class InterceptorsInspectorService extends Service implements Inspector {
public static readonly ID = "INTERCEPTORS_INSPECTOR_SERVICE"
inspectorID = "interceptors"
private t = getI18n()
private readonly inspection = this.bind(InspectionService)
private readonly interceptors = this.bind(InterceptorService)
onServiceInit() {
this.inspection.registerInspector(this)
}
getInspections(
req: Readonly<Ref<HoppRESTRequest | HoppRESTResponseOriginalRequest>>
) {
return computed((): InspectorResult[] => {
const isBinaryBody =
req.value.body.contentType === "application/octet-stream"
// TODO: define the supported capabilities in the interceptor
const isAgent = this.interceptors.currentInterceptorID.value === "agent"
if (isBinaryBody && isAgent) {
return [
{
isApplicable: true,
icon: IconAlertCircle,
severity: 2,
text: {
type: "text",
text: this.t(
"inspections.requestBody.agent_doesnt_support_binary_body"
),
},
locations: {
type: "body-content-type-header",
},
id: "interceptors-inspector-binary-agent-body-content-type-header",
},
]
}
return []
})
}
}

View File

@@ -7,6 +7,7 @@ export const knownContentTypes = {
"text/xml": "xml", "text/xml": "xml",
"application/x-www-form-urlencoded": "multipart", "application/x-www-form-urlencoded": "multipart",
"multipart/form-data": "multipart", "multipart/form-data": "multipart",
"application/octet-stream": "binary",
"text/html": "html", "text/html": "html",
"text/plain": "plain", "text/plain": "plain",
} }

View File

@@ -41,6 +41,10 @@ export const HoppRESTReqBody = z.union([
body: z.array(FormDataKeyValue).catch([]), body: z.array(FormDataKeyValue).catch([]),
showIndividualContentType: z.boolean().optional().catch(false), showIndividualContentType: z.boolean().optional().catch(false),
}), }),
z.object({
contentType: z.literal("application/octet-stream"),
body: z.instanceof(File).nullable().catch(null),
}),
z.object({ z.object({
contentType: z.union([ contentType: z.union([
z.literal("application/json"), z.literal("application/json"),
@@ -50,6 +54,7 @@ export const HoppRESTReqBody = z.union([
z.literal("application/xml"), z.literal("application/xml"),
z.literal("text/xml"), z.literal("text/xml"),
z.literal("application/x-www-form-urlencoded"), z.literal("application/x-www-form-urlencoded"),
z.literal("binary"),
z.literal("text/html"), z.literal("text/html"),
z.literal("text/plain"), z.literal("text/plain"),
]), ]),