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 => { 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 => { 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) => { 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 { 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, environment$: Observable ): Observable> { return combineLatest([request$, environment$]).pipe( map(async ([request, env]) => await getEffectiveRESTRequest(request, env)) ) }