feat: support for binary body (#4466)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
@@ -43,5 +43,5 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
|
||||
active: boolean;
|
||||
description: string;
|
||||
}[];
|
||||
effectiveFinalBody: FormData | string | null;
|
||||
effectiveFinalBody: FormData | string | File | null;
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
>
|
||||
{{ inspector.text.text }}
|
||||
<HoppSmartLink
|
||||
v-if="inspector.doc"
|
||||
blank
|
||||
:to="inspector.doc.link"
|
||||
class="text-accent transition hover:text-accentDark"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
]),
|
||||
|
||||
Reference in New Issue
Block a user