api-client/packages/hoppscotch-common/src/helpers/utils/EffectiveURL.ts
Anwarul Islam 965e4e1d44
feat(common): authentication strategy improvements (#5130)
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
2025-06-25 22:12:09 +05:30

465 lines
12 KiB
TypeScript

import {
Environment,
FormDataKeyValue,
HoppRESTAuth,
HoppRESTHeader,
HoppRESTHeaders,
HoppRESTParam,
HoppRESTParams,
HoppRESTReqBody,
HoppRESTRequest,
parseBodyEnvVariables,
parseRawKeyValueEntriesE,
parseTemplateString,
parseTemplateStringE,
} from "@hoppscotch/data"
import * as A from "fp-ts/Array"
import * as E from "fp-ts/Either"
import { flow, pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as RA from "fp-ts/ReadonlyArray"
import * as S from "fp-ts/string"
import qs from "qs"
import { combineLatest, Observable } from "rxjs"
import { map } from "rxjs/operators"
import { arrayFlatMap, arraySort } from "../functional/array"
import { toFormData } from "../functional/formData"
import { tupleWithSameKeysToRecord } from "../functional/record"
import { isJSONContentType } from "./contenttypes"
import { stripComments } from "../editor/linting/jsonc"
import { generateAuthHeaders, generateAuthParams } from "../auth/auth-types"
export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
/**
* The effective final URL.
*
* This contains path, params and environment variables all applied to it
*/
effectiveFinalURL: string
effectiveFinalHeaders: HoppRESTHeaders
effectiveFinalParams: HoppRESTParams
effectiveFinalBody: FormData | string | null | File | Blob
effectiveFinalRequestVariables: { key: string; value: string }[]
}
/**
* Get headers that can be generated by authorization config of the request
* @param req Request to check
* @param envVars Currently active environment variables
* @param auth Authorization config to check
* @param parse Whether to parse the template strings
* @param showKeyIfSecret Whether to show the key if the value is a secret
* @returns The list of headers
*/
export const getComputedAuthHeaders = async (
envVars: Environment["variables"],
req?:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
},
auth?: HoppRESTRequest["auth"],
parse = true,
showKeyIfSecret = false
) => {
const request = auth ? { auth: auth ?? { authActive: false } } : req
if (!request || !request.auth || !request.auth.authActive) return []
return await generateAuthHeaders(
request.auth,
req as HoppRESTRequest,
envVars,
showKeyIfSecret
)
}
/**
* Get headers that can be generated by body config of the request
* @param req Request to check
* @returns The list of headers
*/
export const getComputedBodyHeaders = (
req:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
}
): HoppRESTHeader[] => {
// If a content-type is already defined, that will override this
if (
req.headers.find(
(req) => req.active && req.key.toLowerCase() === "content-type"
)
)
return []
if (!("body" in req)) return []
// 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,
key: "content-type",
value: req.body.contentType,
description: "",
},
]
}
export type ComputedHeader = {
source: "auth" | "body"
header: HoppRESTHeader
}
/**
* Returns a list of headers that will be added during execution of the request
* For e.g, Authorization headers maybe added if an Auth Mode is defined on REST
* @param req The request to check
* @param envVars The environment variables active
* @param parse Whether to parse the template strings
* @param showKeyIfSecret Whether to show the key if the value is a secret
* @returns The headers that are generated along with the source of that header
*/
export const getComputedHeaders = async (
req:
| HoppRESTRequest
| {
auth: HoppRESTAuth
headers: HoppRESTHeaders
},
envVars: Environment["variables"],
parse = true,
showKeyIfSecret = false
): Promise<ComputedHeader[]> => {
return [
...(
await getComputedAuthHeaders(
envVars,
req,
undefined,
parse,
showKeyIfSecret
)
).map((header) => ({
source: "auth" as const,
header,
})),
...getComputedBodyHeaders(req).map((header) => ({
source: "body" as const,
header,
})),
]
}
export type ComputedParam = {
source: "auth"
param: HoppRESTParam
}
/**
* Returns a list of params that will be added during execution of the request
* For e.g, Authorization params (like API-key) maybe added if an Auth Mode is defined on REST
* @param req The request to check
* @param envVars The environment variables active
* @returns The params that are generated along with the source of that header
*/
export const getComputedParams = async (
req: HoppRESTRequest,
envVars: Environment["variables"]
): Promise<ComputedParam[]> => {
if (!req.auth || !req.auth.authActive) return []
const params = await generateAuthParams(req.auth, req, envVars)
return params.map((param) => ({ source: "auth" as const, param }))
}
// Resolves environment variables in the body
export const resolvesEnvsInBody = (
body: HoppRESTReqBody,
env: Environment
): HoppRESTReqBody => {
if (!body.contentType) return body
if (body.contentType === "application/octet-stream") {
return body
}
if (body.contentType === "multipart/form-data") {
if (!body.body) {
return {
contentType: null,
body: null,
}
}
return {
contentType: "multipart/form-data",
body: body.body.map(
(entry) =>
<FormDataKeyValue>{
active: entry.active,
isFile: entry.isFile,
key: parseTemplateString(entry.key, env.variables, false, true),
value: entry.isFile
? entry.value
: parseTemplateString(entry.value, env.variables, false, true),
contentType: entry.contentType,
}
),
}
}
let bodyContent = ""
if (isJSONContentType(body.contentType))
bodyContent = stripComments(body.body)
if (body.contentType === "application/x-www-form-urlencoded") {
bodyContent = body.body
}
return {
contentType: body.contentType,
body: parseTemplateString(bodyContent, env.variables, false, true),
}
}
export function getFinalBodyFromRequest(
request: HoppRESTRequest,
envVariables: Environment["variables"],
showKeyIfSecret = false
): FormData | Blob | string | null {
if (request.body.contentType === null) return null
if (request.body.contentType === "application/x-www-form-urlencoded") {
const parsedBodyRecord = pipe(
request.body.body ?? "",
parseRawKeyValueEntriesE,
E.map(
flow(
RA.toArray,
/**
* Filtering out empty keys and non-active pairs.
*/
A.filter(({ active, key }) => active && !S.isEmpty(key)),
/**
* Mapping each key-value to template-string-parser with either on array,
* which will be resolved in further steps.
*/
A.map(({ key, value }) => [
parseTemplateStringE(key, envVariables, false, showKeyIfSecret),
parseTemplateStringE(value, envVariables, false, showKeyIfSecret),
]),
/**
* Filtering and mapping only right-eithers for each key-value as [string, string].
*/
A.filterMap(([key, value]) =>
E.isRight(key) && E.isRight(value)
? O.some([key.right, value.right] as [string, string])
: O.none
),
tupleWithSameKeysToRecord,
(obj) => qs.stringify(obj, { indices: false })
)
)
)
return E.isRight(parsedBodyRecord) ? parsedBodyRecord.right : null
}
if (request.body.contentType === "multipart/form-data") {
return pipe(
request.body.body ?? [],
A.filter(
(x) =>
x.key !== "" &&
x.active &&
(typeof x.value === "string" ||
(x.value.length > 0 && x.value[0] instanceof File))
), // Remove empty keys and unsetted file
// Sort files down
arraySort((a, b) => {
if (a.isFile) return 1
if (b.isFile) return -1
return 0
}),
// FormData allows only a single blob in an entry,
// we split array blobs into separate entries (FormData will then join them together during exec)
arrayFlatMap((x) =>
x.isFile
? // @ts-expect-error TODO: Fix this type error
x.value.map((v) => ({
key: parseTemplateString(x.key, envVariables),
value: v as string | Blob,
contentType: x.contentType,
}))
: [
{
key: parseTemplateString(x.key, envVariables),
value: parseTemplateString(x.value, envVariables),
contentType: x.contentType,
},
]
),
toFormData
)
}
if (request.body.contentType === "application/octet-stream") {
return request.body.body
}
let bodyContent = request.body.body ?? ""
if (isJSONContentType(request.body.contentType))
bodyContent = stripComments(request.body.body)
// body can be null if the content-type is not set
return parseBodyEnvVariables(bodyContent, envVariables)
}
/**
* Outputs an executable request format with environment variables applied
*
* @param request The request to source from
* @param environment The environment to apply
* @param showKeyIfSecret Whether to show the key if the value is a secret
*
* @returns An object with extra fields defining a complete request
*/
export async function getEffectiveRESTRequest(
request: HoppRESTRequest,
environment: Environment,
showKeyIfSecret = false
): Promise<EffectiveHoppRESTRequest> {
const effectiveFinalHeaders = pipe(
(await getComputedHeaders(request, environment.variables)).map(
(h) => h.header
),
A.concat(request.headers),
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(
x.key,
environment.variables,
false,
showKeyIfSecret
),
value: parseTemplateString(
x.value,
environment.variables,
false,
showKeyIfSecret
),
description: x.description,
}))
)
const effectiveFinalParams = pipe(
(await getComputedParams(request, environment.variables)).map(
(p) => p.param
),
A.concat(request.params),
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(
x.key,
environment.variables,
false,
showKeyIfSecret
),
value: parseTemplateString(
x.value,
environment.variables,
false,
showKeyIfSecret
),
description: x.description,
}))
)
const effectiveFinalRequestVariables = pipe(
request.requestVariables,
A.filter((x) => x.active && x.key !== ""),
A.map((x) => ({
active: true,
key: parseTemplateString(x.key, environment.variables),
value: parseTemplateString(x.value, environment.variables),
}))
)
const effectiveFinalBody = getFinalBodyFromRequest(
request,
environment.variables,
showKeyIfSecret
)
return {
...request,
effectiveFinalURL: parseTemplateString(
request.endpoint,
environment.variables,
false,
showKeyIfSecret
),
effectiveFinalHeaders,
effectiveFinalParams,
effectiveFinalBody,
effectiveFinalRequestVariables,
}
}
/**
* Creates an Observable Stream that emits HoppRESTRequests whenever
* the input streams emit a value
*
* @param request$ The request stream containing request data
* @param environment$ The environment stream containing environment data to apply
*
* @returns Observable Stream for the Effective Request Object
*/
export function getEffectiveRESTRequestStream(
request$: Observable<HoppRESTRequest>,
environment$: Observable<Environment>
): Observable<Promise<EffectiveHoppRESTRequest>> {
return combineLatest([request$, environment$]).pipe(
map(async ([request, env]) => await getEffectiveRESTRequest(request, env))
)
}