Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
HttpRequestOptions,
InstrumentUsecase,
KeyValuePair,
parseRawBody,
shouldIncludeBody,
} from '@novu/application-generic';
import { createLiquidEngine } from '@novu/framework/internal';
Expand Down Expand Up @@ -53,30 +54,53 @@ export class TestHttpEndpointUsecase {
const method = (compiled.method as string) ?? 'GET';
const compiledHeaders = (compiled.headers as KeyValuePair[]) ?? [];
const compiledBody = (compiled.body as KeyValuePair[]) ?? [];
const bodyMode = (compiled.bodyMode as string) ?? 'key-value';
const rawJsonBody = compiled.rawBody as string | undefined;

const resolvedHeaders: Record<string, string> = Object.fromEntries(
compiledHeaders.filter(({ key }) => key).map(({ key, value }) => [key, value])
);

const resolvedBodyPairs: Record<string, unknown> = Object.fromEntries(
compiledBody.filter(({ key }) => key).map(({ key, value }) => [key, value])
);
const startTime = performance.now();

let resolvedBody: Record<string, unknown> | unknown[];
try {
if (bodyMode === 'raw') {
resolvedBody = rawJsonBody ? parseRawBody(rawJsonBody) : {};
} else {
resolvedBody = Object.fromEntries(
compiledBody.filter(({ key }) => key).map(({ key, value }) => [key, value])
);
}
} catch (parseError) {
const errorMessage = parseError instanceof Error ? parseError.message : 'Failed to parse raw JSON body';

return {
statusCode: 400,
body: { error: `Invalid raw JSON body: ${errorMessage}` },
headers: {},
durationMs: Math.round(performance.now() - startTime),
resolvedRequest: {
url: resolvedUrl,
method,
headers: resolvedHeaders,
},
};
}

const hasBody = shouldIncludeBody(resolvedBodyPairs, method);
const hasBody = shouldIncludeBody(resolvedBody, method);

const secretKey = await this.getDecryptedSecretKey.execute(
GetDecryptedSecretKeyCommand.create({ environmentId: command.user.environmentId })
);
resolvedHeaders['novu-signature'] = buildNovuSignatureHeader(secretKey, hasBody ? resolvedBodyPairs : {});

const startTime = performance.now();
resolvedHeaders['novu-signature'] = buildNovuSignatureHeader(secretKey, hasBody ? resolvedBody : {});

try {
const response = await this.httpClientService.request<string>({
url: resolvedUrl,
method: method as HttpRequestOptions['method'],
headers: resolvedHeaders,
...(hasBody ? { body: resolvedBodyPairs } : {}),
...(hasBody ? { body: resolvedBody } : {}),
timeout: 30_000,
responseType: 'text',
});
Expand All @@ -91,7 +115,7 @@ export class TestHttpEndpointUsecase {
url: resolvedUrl,
method,
headers: resolvedHeaders,
...(hasBody ? { body: resolvedBodyPairs } : {}),
...(hasBody ? { body: resolvedBody } : {}),
},
};
} catch (error) {
Expand All @@ -113,7 +137,7 @@ export class TestHttpEndpointUsecase {
url: resolvedUrl,
method,
headers: resolvedHeaders,
...(hasBody ? { body: resolvedBodyPairs } : {}),
...(hasBody ? { body: resolvedBody } : {}),
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { EmailBody } from '@/components/workflow-editor/steps/email/email-body';
import { EmailSubject } from '@/components/workflow-editor/steps/email/email-subject';
import { EnforceSchemaValidation } from '@/components/workflow-editor/steps/http-request/enforce-schema-validation';
import { KeyValuePairList } from '@/components/workflow-editor/steps/http-request/key-value-pair-list';
import { RawBodyEditor } from '@/components/workflow-editor/steps/http-request/raw-body-editor';
import { RequestEndpoint } from '@/components/workflow-editor/steps/http-request/request-endpoint';
import { ResponseBodySchema } from '@/components/workflow-editor/steps/http-request/response-body-schema';
import { InAppAction } from '@/components/workflow-editor/steps/in-app/in-app-action';
Expand Down Expand Up @@ -145,6 +146,10 @@ export const getComponentByType = ({ component }: { component?: UiComponentEnum
case UiComponentEnum.DESTINATION_BODY:
return <KeyValuePairList fieldName="body" label="Request body" />;

case UiComponentEnum.DESTINATION_BODY_MODE:
case UiComponentEnum.DESTINATION_RAW_BODY:
return <RawBodyEditor />;

case UiComponentEnum.DESTINATION_RESPONSE_BODY_SCHEMA:
return <ResponseBodySchema />;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ export function ConfigureHttpRequestStepPreview({ controlValues, className }: Co
const method = (controlValues.method as string) ?? 'GET';
const headers = ((controlValues.headers as KeyValuePair[]) ?? []).filter((h) => h.key);
const body = (controlValues.body as KeyValuePair[]) ?? [];
const rawBody = (controlValues.rawBody as string) ?? null;
const bodyMode = (controlValues.bodyMode as string) ?? null;

const urlDisplay = getUrlDisplay(url);
const curlString = buildRawCurlString(url, method, headers, body);
const curlString = buildRawCurlString(url, method, headers, body, undefined, rawBody, bodyMode);

return (
<div className={`overflow-hidden rounded-lg border border-[#e1e4ea] ${className ?? ''}`}>
Expand All @@ -28,7 +30,7 @@ export function ConfigureHttpRequestStepPreview({ controlValues, className }: Co
</div>

<div className="relative overflow-hidden bg-white p-2">
<CurlDisplay url={url} method={method} headers={headers} body={body} className="whitespace-pre text-[10px]" />
<CurlDisplay url={url} method={method} headers={headers} body={body} rawBody={rawBody} bodyMode={bodyMode} className="whitespace-pre text-[10px]" />
<div className="pointer-events-none absolute right-0 top-0 h-full w-12 bg-linear-to-r from-transparent to-white" />
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cn } from '@/utils/ui';
import { canMethodHaveBody, type KeyValuePair, NOVU_SIGNATURE_HEADER_KEY } from './curl-utils';
import { canMethodHaveBody, escapeShellSingleQuoted, type KeyValuePair, NOVU_SIGNATURE_HEADER_KEY } from './curl-utils';

type CurlDisplayProps = {
url: string;
Expand All @@ -8,55 +8,71 @@ type CurlDisplayProps = {
body?: KeyValuePair[] | Record<string, unknown> | null;
className?: string;
novuSignature?: string;
rawBody?: string | null;
bodyMode?: string | null;
};

export function CurlDisplay({ url, method, headers, body, className, novuSignature }: CurlDisplayProps) {
export function CurlDisplay({ url, method, headers, body, className, novuSignature, rawBody, bodyMode }: CurlDisplayProps) {
const headerEntries: [string, string][] = Array.isArray(headers)
? headers.filter((h) => h.key).map((h) => [h.key, h.value])
: Object.entries(headers);

const hasNovuSignature = headerEntries.some(([k]) => k.toLowerCase() === NOVU_SIGNATURE_HEADER_KEY);

const canHaveBody = canMethodHaveBody(method);
let bodyObj: Record<string, unknown> | null = null;
let bodyStr: string | null = null;

if (canHaveBody && body) {
if (Array.isArray(body)) {
const pairs = body.filter((b) => b.key);
if (canHaveBody) {
if (bodyMode === 'raw') {
// Raw mode is exclusive — never fall back to key-value pairs
if (rawBody) {
bodyStr = rawBody;
}
} else if (body) {
let bodyObj: Record<string, unknown> | null = null;

if (Array.isArray(body)) {
const pairs = body.filter((b) => b.key);

if (pairs.length > 0) {
bodyObj = Object.fromEntries(pairs.map(({ key, value }) => [key, value]));
}
} else if (Object.keys(body).length > 0) {
bodyObj = body;
}

if (pairs.length > 0) {
bodyObj = Object.fromEntries(pairs.map(({ key, value }) => [key, value]));
if (bodyObj) {
bodyStr = JSON.stringify(bodyObj);
}
} else if (Object.keys(body).length > 0) {
bodyObj = body;
}
}

return (
<div className={cn('font-mono text-xs', className)}>
<p className="my-0 leading-[1.5]">
<span className="text-[#99a0ae]">{'novu $ '}</span>
<span className="text-[#0e121b]">{'curl --location '}</span>
<span className="text-[#7d52f4]">{`'${url || 'https://api.example.com/endpoint'}' `}</span>
<span className="text-[#0e121b]">{'curl --location --request '}</span>
<span className="text-[#fb4ba3]">{`'${escapeShellSingleQuoted(method.toUpperCase())}' `}</span>
<span className="text-[#7d52f4]">{`'${escapeShellSingleQuoted(url || 'https://api.example.com/endpoint')}' `}</span>
</p>
{novuSignature && !hasNovuSignature && (
<p className="my-0 leading-[1.5] opacity-60">
<span className="text-[#0e121b]">{'--header '}</span>
<span className="text-[#fb4ba3]">{`'${NOVU_SIGNATURE_HEADER_KEY}`}</span>
<span className="text-[#7d52f4]">{`: ${novuSignature}' `}</span>
<span className="text-[#fb4ba3]">{`'${escapeShellSingleQuoted(NOVU_SIGNATURE_HEADER_KEY)}`}</span>
<span className="text-[#7d52f4]">{`: ${escapeShellSingleQuoted(novuSignature)}' `}</span>
</p>
)}
{headerEntries.map(([key, val]) => (
<p key={key} className="my-0 leading-[1.5]">
<span className="text-[#0e121b]">{'--header '}</span>
<span className="text-[#fb4ba3]">{`'${key}`}</span>
<span className="text-[#7d52f4]">{`: ${val}' `}</span>
<span className="text-[#fb4ba3]">{`'${escapeShellSingleQuoted(key)}`}</span>
<span className="text-[#7d52f4]">{`: ${escapeShellSingleQuoted(val)}' `}</span>
</p>
))}
{bodyObj && (
{bodyStr && (
<p className="my-0 leading-[1.5]">
<span className="text-[#0e121b]">{'--data '}</span>
<span className="text-[#7d52f4]">{`'${JSON.stringify(bodyObj)}' `}</span>
<span className="text-[#7d52f4]">{`'${escapeShellSingleQuoted(bodyStr)}' `}</span>
</p>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ export function canMethodHaveBody(method: string): boolean {
return METHODS_WITH_BODY.has(method.toUpperCase());
}

// Escape single quotes for safe interpolation inside POSIX shell single-quoted strings.
// Single quotes cannot appear inside single-quoted strings, so we close, escape, and reopen.
export function escapeShellSingleQuoted(value: string): string {
return value.replace(/'/g, "'\\''");
}

export function buildRawCurlString(
url: string,
method: string,
headers: KeyValuePair[] | Record<string, string>,
body: KeyValuePair[] | Record<string, unknown> | null | undefined,
novuSignature?: string
novuSignature?: string,
rawBody?: string | null,
bodyMode?: string | null
): string {
const headerEntries: [string, string][] = Array.isArray(headers)
? headers.filter((h) => h.key).map((h) => [h.key, h.value])
Expand All @@ -25,27 +33,42 @@ export function buildRawCurlString(
headerEntries.unshift([NOVU_SIGNATURE_HEADER_KEY, novuSignature]);
}

const headerArgs = headerEntries.map(([k, v]) => `--header '${k}: ${v}'`).join(' \\\n');
const headerArgs = headerEntries
.map(([k, v]) => `--header '${escapeShellSingleQuoted(k)}: ${escapeShellSingleQuoted(v)}'`)
.join(' \\\n');

const canHaveBody = canMethodHaveBody(method);
let bodyObj: Record<string, unknown> | null = null;
let bodyStr = '';

if (canHaveBody) {
if (Array.isArray(body)) {
const pairs = body.filter((b) => b.key);
if (bodyMode === 'raw') {
// Raw mode is exclusive — never fall back to key-value pairs
if (rawBody) {
bodyStr = `--data '${escapeShellSingleQuoted(rawBody)}'`;
}
} else {
let bodyObj: Record<string, unknown> | null = null;

if (Array.isArray(body)) {
const pairs = body.filter((b) => b.key);

if (pairs.length > 0) {
bodyObj = Object.fromEntries(pairs.map(({ key, value }) => [key, value]));
if (pairs.length > 0) {
bodyObj = Object.fromEntries(pairs.map(({ key, value }) => [key, value]));
}
} else if (body && Object.keys(body).length > 0) {
bodyObj = body;
}

if (bodyObj) {
bodyStr = `--data '${escapeShellSingleQuoted(JSON.stringify(bodyObj))}'`;
}
} else if (body && Object.keys(body).length > 0) {
bodyObj = body;
}
}

const bodyStr = bodyObj ? `--data '${JSON.stringify(bodyObj)}'` : '';
const parts = [`novu $ curl --location '${url || 'https://api.example.com/endpoint'}'`, headerArgs, bodyStr].filter(
Boolean
);
const parts = [
`novu $ curl --location --request '${escapeShellSingleQuoted(method.toUpperCase())}' '${escapeShellSingleQuoted(url || 'https://api.example.com/endpoint')}'`,
headerArgs,
bodyStr,
].filter(Boolean);

return parts.join(' \\\n');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,10 @@ function PreTestState({ novuSignature, onTest }: { novuSignature?: string; onTes
const method = (controlValues?.method as string) ?? 'GET';
const headers = (controlValues?.headers as KeyValuePair[]) ?? [];
const body = (controlValues?.body as KeyValuePair[]) ?? [];
const controlRawBody = (controlValues?.rawBody as string) ?? null;
const controlBodyMode = (controlValues?.bodyMode as string) ?? null;

const curlString = buildRawCurlString(url, method, headers, body, novuSignature);
const curlString = buildRawCurlString(url, method, headers, body, novuSignature, controlRawBody, controlBodyMode);
const activeHeaders = headers.filter((h) => h.key);

const handleCopyCurlSuccess = useCallback(() => {
Expand Down Expand Up @@ -331,7 +333,7 @@ function PreTestState({ novuSignature, onTest }: { novuSignature?: string; onTes
</>
}
>
<CurlDisplay url={url} method={method} headers={activeHeaders} body={body} novuSignature={novuSignature} />
<CurlDisplay url={url} method={method} headers={activeHeaders} body={body} novuSignature={novuSignature} rawBody={controlRawBody} bodyMode={controlBodyMode} />
</BrowserShell>

<div className="flex items-center justify-between overflow-clip rounded-md border border-[#e1e4ea] bg-[#fbfbfb] px-2 py-1.5 ">
Expand Down Expand Up @@ -390,8 +392,10 @@ function ErrorState({
const method = (controlValues?.method as string) ?? 'GET';
const headers = ((controlValues?.headers as KeyValuePair[]) ?? []).filter((h) => h.key);
const body = (controlValues?.body as KeyValuePair[]) ?? [];
const errorRawBody = (controlValues?.rawBody as string) ?? null;
const errorBodyMode = (controlValues?.bodyMode as string) ?? null;

const curlString = buildRawCurlString(url, method, headers, body, novuSignature);
const curlString = buildRawCurlString(url, method, headers, body, novuSignature, errorRawBody, errorBodyMode);

const responseToCopy = rawBody ? JSON.stringify(rawBody, null, 2) : error.message;

Expand Down Expand Up @@ -456,7 +460,7 @@ function ErrorState({
</>
}
>
<CurlDisplay url={url} method={method} headers={headers} body={body} novuSignature={novuSignature} />
<CurlDisplay url={url} method={method} headers={headers} body={body} novuSignature={novuSignature} rawBody={errorRawBody} bodyMode={errorBodyMode} />
</BrowserShell>

<div className="flex flex-col gap-3">
Expand Down Expand Up @@ -507,6 +511,8 @@ export function HttpRequestConsolePreview() {
method: controlValues?.method,
headers: controlValues?.headers,
body: controlValues?.body,
bodyMode: controlValues?.bodyMode,
rawBody: controlValues?.rawBody,
});
const prevControlsKeyRef = useRef<string | null>(null);

Expand Down
Loading
Loading