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;
description: string;
}[];
effectiveFinalBody: FormData | string | null;
effectiveFinalBody: FormData | string | File | null;
}

View File

@@ -360,7 +360,7 @@ export async function getEffectiveRESTRequest(
function getFinalBodyFromRequest(
request: HoppRESTRequest,
resolvedVariables: EnvironmentVariable[]
): E.Either<HoppCLIError, string | null | FormData> {
): E.Either<HoppCLIError, string | null | FormData | File> {
if (request.body.contentType === 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(
parseBodyEnvVariablesE(request.body.body, resolvedVariables),
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.",
"extention_enable_action": "Enable Browser Extension",
"extention_not_enabled": "Extension not enabled."
},
"requestBody": {
"agent_doesnt_support_binary_body": "Sending binary data via agent is not supported yet"
}
},
"layout": {
@@ -666,7 +669,8 @@
"content_type_titles": {
"others": "Others",
"structured": "Structured",
"text": "Text"
"text": "Text",
"binary": "Binary"
},
"show_content_type": "Show Content Type",
"different_collection": "Cannot reorder requests from different collections",

View File

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

View File

@@ -35,6 +35,7 @@
@click="
() => {
body.contentType = null
body.body = null
hide()
}
"
@@ -76,6 +77,7 @@
</div>
</template>
</tippy>
<AppInspection :inspection-results="tabResults" />
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="t('request.override_help')"
@@ -107,6 +109,10 @@
v-model="body"
:envs="envs"
/>
<HttpBodyBinary
v-else-if="body.contentType === 'application/octet-stream'"
v-model="body"
/>
<HttpRawBody v-else-if="body.contentType !== null" v-model="body" />
<HoppSmartPlaceholder
v-if="body.contentType == null"
@@ -144,6 +150,9 @@ import IconInfo from "~icons/lucide/info"
import IconRefreshCW from "~icons/lucide/refresh-cw"
import { RESTOptionTabs } from "./RequestOptions.vue"
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 t = useI18n()
@@ -195,4 +204,12 @@ const isContentTypeAlreadyExist = () => {
// Template refs
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>

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 RA from "fp-ts/ReadonlyArray"
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 { computedAsync, useVModel } from "@vueuse/core"
@@ -546,16 +546,22 @@ const clearContent = () => {
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs())
const computedHeaders = computedAsync(
async () =>
(await getComputedHeaders(request.value, aggregateEnvs.value, false)).map(
(header, index) => ({
id: `header-${index}`,
...header,
})
),
[]
)
const computedHeaders: Ref<
{
source: "auth" | "body"
header: HoppRESTHeader
id: string
}[]
> = 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 () => {
if (!props.inheritedProperties?.auth || !props.inheritedProperties.headers)
@@ -671,7 +677,11 @@ const headerValueResults = inspectionService.getResultViewFor(
const getInspectorResult = (results: InspectorResult[], index: number) => {
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 result.locations.index === index
})

View File

@@ -379,7 +379,11 @@ const parameterValueResults = inspectionService.getResultViewFor(
const getInspectorResult = (results: InspectorResult[], index: number) => {
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 result.locations.index === index
})

View File

@@ -44,7 +44,7 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
effectiveFinalURL: string
effectiveFinalHeaders: HoppRESTHeaders
effectiveFinalParams: HoppRESTParams
effectiveFinalBody: FormData | string | null
effectiveFinalBody: FormData | string | null | File
effectiveFinalRequestVariables: { key: string; value: string }[]
}
@@ -249,6 +249,32 @@ export const getComputedBodyHeaders = (
// Body should have a non-null content-type
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 [
{
active: true,
@@ -408,6 +434,10 @@ export const resolvesEnvsInBody = (
): HoppRESTReqBody => {
if (!body.contentType) return body
if (body.contentType === "application/octet-stream") {
return body
}
if (body.contentType === "multipart/form-data") {
if (!body.body) {
return {
@@ -448,7 +478,7 @@ function getFinalBodyFromRequest(
request: HoppRESTRequest,
envVariables: Environment["variables"],
showKeyIfSecret = false
): FormData | string | null {
): FormData | Blob | string | null {
if (request.body.contentType === null) return null
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 ?? ""
if (isJSONContentType(request.body.contentType))

View File

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

View File

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

View File

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

View File

@@ -100,7 +100,8 @@ export class EnvironmentInspectorService extends Service implements Inspector {
position:
locations.type === "url" ||
locations.type === "body" ||
locations.type === "response"
locations.type === "response" ||
locations.type === "body-content-type-header"
? "key"
: locations.position,
index: index,
@@ -222,7 +223,8 @@ export class EnvironmentInspectorService extends Service implements Inspector {
position:
locations.type === "url" ||
locations.type === "body" ||
locations.type === "response"
locations.type === "response" ||
locations.type === "body-content-type-header"
? "key"
: locations.position,
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",
"application/x-www-form-urlencoded": "multipart",
"multipart/form-data": "multipart",
"application/octet-stream": "binary",
"text/html": "html",
"text/plain": "plain",
}

View File

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