feat(relay): add transaction mode to requests (#49)

* feat(ui): initialize scroll masks onmount

* feat(relay): add transaction mode to requests

* test: set sqlite as default connection

* test(relay): add missing test

* chore: clearer access to implementation

* style: apply rector

* style: apply php style fixes

* test: ts type fixes
This commit is contained in:
Mazen Touati
2026-01-26 01:03:55 +01:00
committed by GitHub
parent 9118ad6d20
commit aa99eacd2c
26 changed files with 881 additions and 269 deletions

View File

@@ -44,7 +44,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
v-bind="{ ...forwarded, ...$attrs }" v-bind="{ ...forwarded, ...$attrs }"
:class=" :class="
cn( cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--reka-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden', 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--reka-popover-content-transform-origin) rounded-md border p-2.5 shadow-md outline-hidden',
props.class, props.class,
) )
" "

View File

@@ -3,37 +3,11 @@
* @component RequestBuilderEndpoint * @component RequestBuilderEndpoint
* @description The endpoint input and method selector for the request builder. * @description The endpoint input and method selector for the request builder.
*/ */
import { AppButton } from '@/components/base/button';
import {
AppDropdownMenu,
AppDropdownMenuContent,
AppDropdownMenuGroup,
AppDropdownMenuItem,
AppDropdownMenuLabel,
AppDropdownMenuTrigger,
} from '@/components/base/dropdown-menu';
import { AppInput } from '@/components/base/input';
import {
AppSelect,
AppSelectContent,
AppSelectGroup,
AppSelectItem,
AppSelectLabel,
AppSelectSeparator,
AppSelectTrigger,
AppSelectValue,
} from '@/components/base/select';
import { useRouteSegmentSelection } from '@/composables/request/useRouteSegmentSelection';
import { type RouteDefinition } from '@/interfaces/routes/routes';
import { useConfigStore, useRequestsHistoryStore, useRequestStore } from '@/stores';
import { generateCurlCommand } from '@/utils/request';
import { buildShareableUrl, encodeShareablePayload } from '@/utils/shareableLinks';
import { cn } from '@/utils/ui'; import { cn } from '@/utils/ui';
import { CodeXml, CornerDownLeftIcon, Link2, SparklesIcon } from 'lucide-vue-next'; import { type HTMLAttributes } from 'vue';
import { computed, type HTMLAttributes, ref } from 'vue'; import RequestBuilderEndpointInput from './RequestBuilderEndpointInput.vue';
import { toast } from 'vue-sonner'; import RequestBuilderMethodSelector from './RequestBuilderMethodSelector.vue';
import CurlExportDialog from './CurlExportDialog.vue'; import RequestBuilderOptionsMenu from './RequestBuilderOptionsMenu.vue';
import ShareableLinkDialog from './ShareableLinkDialog.vue';
/* /*
* Types & Interfaces. * Types & Interfaces.
@@ -48,240 +22,16 @@ export interface AppRequestBuilderEndpointProps {
*/ */
const props = defineProps<AppRequestBuilderEndpointProps>(); const props = defineProps<AppRequestBuilderEndpointProps>();
/*
* Stores.
*/
const requestStore = useRequestStore();
const configStore = useConfigStore();
const historyStore = useRequestsHistoryStore();
/*
* State.
*/
const showCurlDialog = ref(false);
const curlCommand = ref('');
const hasSpecialAuth = ref(false);
const availableMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
const showShareableLinkDialog = ref(false);
const shareableLink = ref('');
/*
* Computed & Methods.
*/
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const endpoint = computed({
get: () => pendingRequestData.value?.endpoint ?? '',
set: (value: string) => requestStore.updateRequestEndpoint(value),
});
const method = computed({
get: () => pendingRequestData.value?.method?.toUpperCase() ?? 'GET',
set: (value: string) => requestStore.updateRequestMethod(value.toUpperCase()),
});
const currentRouteSupportedMethods = computed(() => {
const supportedRoutes = requestStore.pendingRequestData?.supportedRoutes;
const supportedMethods =
supportedRoutes?.map((route: RouteDefinition) => route.method) ?? [];
return availableMethods.filter((method: string) => supportedMethods.includes(method));
});
const currentRouteUnsupportedMethods = computed(() => {
return availableMethods.filter(
(method: string) => !currentRouteSupportedMethods.value.includes(method),
);
});
/*
* Route segment selection.
*/
const { handleClick: autoSelectRouteVariableSegmentWhenApplicable } =
useRouteSegmentSelection({ endpoint });
/*
* Actions.
*/
const executeCurrentRequest = async function () {
if (!requestStore.pendingRequestData) {
return;
}
await requestStore.executeCurrentRequest();
};
/**
* Executes request of Enter key is pressed.
*/
const executeCurrentRequestWhenEnterIsPressed = (event: KeyboardEvent) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
executeCurrentRequest();
};
/**
* Generates and displays cURL command for the current request.
*/
const populateCurlCommandExporterDialog = () => {
if (!requestStore.pendingRequestData) {
return;
}
const result = generateCurlCommand(
requestStore.pendingRequestData,
configStore.apiUrl,
);
curlCommand.value = result.command;
hasSpecialAuth.value = result.hasSpecialAuth;
showCurlDialog.value = true;
};
/**
* Generates and shows shareable link dialog for the current request state.
*/
const openShareableLinkDialog = () => {
if (!requestStore.pendingRequestData) {
return;
}
try {
const lastLog = historyStore.lastLog;
const response = lastLog?.response;
const applicationKey = configStore.activeApplication ?? undefined;
const encodedPayload = encodeShareablePayload(
requestStore.pendingRequestData,
response,
lastLog ?? undefined,
applicationKey,
);
shareableLink.value = buildShareableUrl(configStore.appBasePath, encodedPayload);
showShareableLinkDialog.value = true;
} catch (error) {
console.error('Failed to generate shareable link:', error);
toast.error('Failed to generate shareable link', {
description: 'An unexpected error occurred.',
});
}
};
</script> </script>
<template> <template>
<div :class="cn('flex', props.class)" data-testid="request-builder-endpoint"> <div :class="cn('flex', props.class)" data-testid="request-builder-endpoint">
<AppSelect v-model="method"> <RequestBuilderMethodSelector />
<AppSelectTrigger
variant="toolbar" <RequestBuilderEndpointInput>
class="h-full w-[95px] border-r pr-1.5 pl-5 text-xs" <template #options-menu>
> <RequestBuilderOptionsMenu />
<AppSelectValue :placeholder="method ? '' : 'Select a Method'"> </template>
{{ method || 'Select a Method' }} </RequestBuilderEndpointInput>
</AppSelectValue>
</AppSelectTrigger>
<AppSelectContent :align-offset="2">
<AppSelectGroup v-if="currentRouteSupportedMethods.length">
<AppSelectLabel>Supported</AppSelectLabel>
<AppSelectItem
v-for="supportedMethod in currentRouteSupportedMethods"
:key="supportedMethod"
:value="supportedMethod"
>
{{ supportedMethod }}
</AppSelectItem>
</AppSelectGroup>
<AppSelectGroup v-if="currentRouteUnsupportedMethods.length !== 0">
<template v-if="currentRouteSupportedMethods.length">
<AppSelectSeparator />
<AppSelectLabel>Other</AppSelectLabel>
</template>
<AppSelectItem
v-for="unsupportedMethod in currentRouteUnsupportedMethods"
:key="unsupportedMethod"
:value="unsupportedMethod"
>
{{ unsupportedMethod }}
</AppSelectItem>
</AppSelectGroup>
</AppSelectContent>
</AppSelect>
<div class="flex flex-1 items-center">
<AppInput
ref="inputRef"
v-model="endpoint"
variant="toolbar"
class="h-full flex-1 text-xs"
placeholder="<endpoint>"
data-testid="endpoint-input"
@click="autoSelectRouteVariableSegmentWhenApplicable"
@keydown="executeCurrentRequestWhenEnterIsPressed"
/>
<div class="flex gap-2 pr-2">
<AppButton
size="xs"
:disabled="!pendingRequestData || pendingRequestData?.isProcessing"
class="gap-0"
@click="executeCurrentRequest"
>
Send (
<CornerDownLeftIcon class="size-3 px-0" />
)
</AppButton>
<AppDropdownMenu>
<AppDropdownMenuTrigger as-child>
<AppButton
variant="outline"
size="xs"
:disabled="!pendingRequestData"
data-testid="request-options-button"
title="Request Options"
>
<SparklesIcon class="size-4" />
</AppButton>
</AppDropdownMenuTrigger>
<AppDropdownMenuContent align="end" class="w-48">
<AppDropdownMenuLabel>Export</AppDropdownMenuLabel>
<AppDropdownMenuGroup>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="export-curl-option"
@select="populateCurlCommandExporterDialog"
>
<CodeXml class="mr-2 size-2" />
<span>Export to cURL</span>
</AppDropdownMenuItem>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="copy-shareable-link-option"
@select="openShareableLinkDialog"
>
<Link2 class="mr-2 size-2" />
<span>Copy Shareable Link</span>
</AppDropdownMenuItem>
</AppDropdownMenuGroup>
</AppDropdownMenuContent>
</AppDropdownMenu>
</div>
</div>
</div> </div>
<CurlExportDialog
v-model:open="showCurlDialog"
:command="curlCommand"
:has-special-auth="hasSpecialAuth"
/>
<ShareableLinkDialog v-model:open="showShareableLinkDialog" :link="shareableLink" />
</template> </template>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
/**
* @component RequestBuilderEndpointInput
* @description The endpoint input field and send button for the request builder.
*/
import { AppButton } from '@/components/base/button';
import { AppInput } from '@/components/base/input';
import { useRouteSegmentSelection } from '@/composables/request/useRouteSegmentSelection';
import { useRequestStore } from '@/stores';
import { CornerDownLeftIcon } from 'lucide-vue-next';
import { computed } from 'vue';
/*
* Stores.
*/
const requestStore = useRequestStore();
/*
* Computed & Methods.
*/
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const endpoint = computed({
get: () => pendingRequestData.value?.endpoint ?? '',
set: (value: string) => requestStore.updateRequestEndpoint(value),
});
const { handleClick: autoSelectRouteVariableSegmentWhenApplicable } =
useRouteSegmentSelection({ endpoint });
const executeCurrentRequest = async function () {
if (!requestStore.pendingRequestData) {
return;
}
await requestStore.executeCurrentRequest();
};
const executeCurrentRequestWhenEnterIsPressed = (event: KeyboardEvent) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
executeCurrentRequest();
};
</script>
<template>
<div class="flex flex-1 items-center">
<AppInput
v-model="endpoint"
variant="toolbar"
class="h-full flex-1 text-xs"
placeholder="<endpoint>"
data-testid="endpoint-input"
@click="autoSelectRouteVariableSegmentWhenApplicable"
@keydown="executeCurrentRequestWhenEnterIsPressed"
/>
<div class="flex gap-2 pr-2">
<AppButton
size="xs"
:disabled="!pendingRequestData || pendingRequestData?.isProcessing"
class="gap-0"
@click="executeCurrentRequest"
>
Send (
<CornerDownLeftIcon class="size-3 px-0" />
)
</AppButton>
<slot name="options-menu" />
</div>
</div>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
/**
* @component RequestBuilderMethodSelector
* @description HTTP method selector for the request builder, with route support awareness.
*/
import {
AppSelect,
AppSelectContent,
AppSelectGroup,
AppSelectItem,
AppSelectLabel,
AppSelectSeparator,
AppSelectTrigger,
AppSelectValue,
} from '@/components/base/select';
import { type RouteDefinition } from '@/interfaces/routes/routes';
import { useRequestStore } from '@/stores';
import { computed } from 'vue';
/*
* Stores.
*/
const requestStore = useRequestStore();
/*
* State.
*/
const availableMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
/*
* Computed & Methods.
*/
const method = computed({
get: () => requestStore.pendingRequestData?.method?.toUpperCase() ?? 'GET',
set: (value: string) => requestStore.updateRequestMethod(value.toUpperCase()),
});
const currentRouteSupportedMethods = computed(() => {
const supportedRoutes = requestStore.pendingRequestData?.supportedRoutes;
const supportedMethods =
supportedRoutes?.map((route: RouteDefinition) => route.method) ?? [];
return availableMethods.filter((m: string) => supportedMethods.includes(m));
});
const currentRouteUnsupportedMethods = computed(() => {
return availableMethods.filter(
(m: string) => !currentRouteSupportedMethods.value.includes(m),
);
});
</script>
<template>
<AppSelect v-model="method">
<AppSelectTrigger
variant="toolbar"
class="h-full w-[95px] border-r pr-1.5 pl-5 text-xs"
>
<AppSelectValue :placeholder="method ? '' : 'Select a Method'">
{{ method || 'Select a Method' }}
</AppSelectValue>
</AppSelectTrigger>
<AppSelectContent :align-offset="2">
<AppSelectGroup v-if="currentRouteSupportedMethods.length">
<AppSelectLabel>Supported</AppSelectLabel>
<AppSelectItem
v-for="supportedMethod in currentRouteSupportedMethods"
:key="supportedMethod"
:value="supportedMethod"
>
{{ supportedMethod }}
</AppSelectItem>
</AppSelectGroup>
<AppSelectGroup v-if="currentRouteUnsupportedMethods.length !== 0">
<template v-if="currentRouteSupportedMethods.length">
<AppSelectSeparator />
<AppSelectLabel>Other</AppSelectLabel>
</template>
<AppSelectItem
v-for="unsupportedMethod in currentRouteUnsupportedMethods"
:key="unsupportedMethod"
:value="unsupportedMethod"
>
{{ unsupportedMethod }}
</AppSelectItem>
</AppSelectGroup>
</AppSelectContent>
</AppSelect>
</template>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
/**
* @component RequestBuilderOptionsMenu
* @description Dropdown menu for request options like Transaction Mode, cURL export, and shareable links.
*/
import { AppButton } from '@/components/base/button';
import {
AppDropdownMenu,
AppDropdownMenuContent,
AppDropdownMenuGroup,
AppDropdownMenuItem,
AppDropdownMenuLabel,
AppDropdownMenuSeparator,
AppDropdownMenuTrigger,
} from '@/components/base/dropdown-menu';
import {
AppPopover,
AppPopoverContent,
AppPopoverTrigger,
} from '@/components/base/popover';
import { AppSwitch } from '@/components/base/switch';
import { useConfigStore, useRequestsHistoryStore, useRequestStore } from '@/stores';
import { generateCurlCommand } from '@/utils/request';
import { buildShareableUrl, encodeShareablePayload } from '@/utils/shareableLinks';
import { CircleHelp, CodeXml, Link2, SparklesIcon } from 'lucide-vue-next';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import CurlExportDialog from './CurlExportDialog.vue';
import ShareableLinkDialog from './ShareableLinkDialog.vue';
/*
* Stores.
*/
const requestStore = useRequestStore();
const configStore = useConfigStore();
const historyStore = useRequestsHistoryStore();
/*
* State.
*/
const showCurlDialog = ref(false);
const curlCommand = ref('');
const hasSpecialAuth = ref(false);
const showShareableLinkDialog = ref(false);
const shareableLink = ref('');
/*
* Computed & Methods.
*/
const pendingRequestData = computed(() => requestStore.pendingRequestData);
const transactionMode = computed({
get: () => pendingRequestData.value?.transactionMode ?? false,
set: (value: boolean) => requestStore.updateTransactionMode(value),
});
/**
* Generates and displays cURL command for the current request.
*/
const populateCurlCommandExporterDialog = () => {
if (!requestStore.pendingRequestData) {
return;
}
const result = generateCurlCommand(
requestStore.pendingRequestData,
configStore.apiUrl,
);
curlCommand.value = result.command;
hasSpecialAuth.value = result.hasSpecialAuth;
showCurlDialog.value = true;
};
/**
* Generates and shows shareable link dialog for the current request state.
*/
const openShareableLinkDialog = () => {
if (!requestStore.pendingRequestData) {
return;
}
try {
const lastLog = historyStore.lastLog;
const response = lastLog?.response;
const applicationKey = configStore.activeApplication ?? undefined;
const encodedPayload = encodeShareablePayload(
requestStore.pendingRequestData,
response,
lastLog ?? undefined,
applicationKey,
);
shareableLink.value = buildShareableUrl(configStore.appBasePath, encodedPayload);
showShareableLinkDialog.value = true;
} catch (error) {
console.error('Failed to generate shareable link:', error);
toast.error('Failed to generate shareable link', {
description: 'An unexpected error occurred.',
});
}
};
</script>
<template>
<AppDropdownMenu>
<AppDropdownMenuTrigger as-child>
<AppButton
variant="outline"
size="xs"
:disabled="!pendingRequestData"
data-testid="request-options-button"
title="Request Options"
>
<SparklesIcon class="size-4" />
</AppButton>
</AppDropdownMenuTrigger>
<AppDropdownMenuContent align="end" class="w-48">
<AppDropdownMenuLabel>Options</AppDropdownMenuLabel>
<AppDropdownMenuGroup>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="transaction-mode-option"
@select.prevent
>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-1.5 font-normal">
<span>Transaction Mode</span>
<AppPopover>
<AppPopoverTrigger
ref="transaction-mode-option"
as-child
@click.stop.prevent
>
<CircleHelp
class="h-3.5 w-3.5 cursor-help text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
/>
</AppPopoverTrigger>
<AppPopoverContent
class="w-80"
side="right"
:side-offset="5"
>
<div class="space-y-2">
<h4 class="text-sm leading-none font-medium">
Transaction Mode
</h4>
<p
class="text-muted-foreground text-xs leading-relaxed"
>
Executes the request within a database
transaction that is automatically rolled back
after completion. This allows you to test
operations without affecting your persistent
data.
</p>
</div>
</AppPopoverContent>
</AppPopover>
</div>
<AppSwitch
v-model="transactionMode"
:variant="{ type: 'compact', default: 'default' }"
class="ml-2"
@click.stop
/>
</div>
</AppDropdownMenuItem>
</AppDropdownMenuGroup>
<AppDropdownMenuSeparator />
<AppDropdownMenuLabel>Export</AppDropdownMenuLabel>
<AppDropdownMenuGroup>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="export-curl-option"
@select="populateCurlCommandExporterDialog"
>
<CodeXml class="mr-2 size-4" />
<span>Export to cURL</span>
</AppDropdownMenuItem>
<AppDropdownMenuItem
class="cursor-pointer text-xs"
data-testid="copy-shareable-link-option"
@select="openShareableLinkDialog"
>
<Link2 class="mr-2 size-4" />
<span>Copy Shareable Link</span>
</AppDropdownMenuItem>
</AppDropdownMenuGroup>
</AppDropdownMenuContent>
</AppDropdownMenu>
<CurlExportDialog
v-model:open="showCurlDialog"
:command="curlCommand"
:has-special-auth="hasSpecialAuth"
/>
<ShareableLinkDialog v-model:open="showShareableLinkDialog" :link="shareableLink" />
</template>

View File

@@ -3,12 +3,14 @@
* @component ResponseViewerResponse * @component ResponseViewerResponse
* @description Renders the successful response details, including body, headers, and cookies. * @description Renders the successful response details, including body, headers, and cookies.
*/ */
import { AppBadge } from '@/components/base/badge';
import { import {
AppTabs, AppTabs,
AppTabsContent, AppTabsContent,
AppTabsList, AppTabsList,
AppTabsTrigger, AppTabsTrigger,
} from '@/components/base/tabs'; } from '@/components/base/tabs';
import { AppTooltipWrapper } from '@/components/base/tooltip';
import ResponseBody from '@/components/domain/Client/Response/ResponseBody/ResponseBody.vue'; import ResponseBody from '@/components/domain/Client/Response/ResponseBody/ResponseBody.vue';
import ResponseDumpAndDie from '@/components/domain/Client/Response/ResponseBody/ResponseDumpAndDie.vue'; import ResponseDumpAndDie from '@/components/domain/Client/Response/ResponseBody/ResponseDumpAndDie.vue';
import ResponseCookies from '@/components/domain/Client/Response/ResponseCookies/ResponseCookies.vue'; import ResponseCookies from '@/components/domain/Client/Response/ResponseCookies/ResponseCookies.vue';
@@ -18,6 +20,7 @@ import { STATUS } from '@/interfaces/http';
import { useRequestsHistoryStore, useRequestStore } from '@/stores'; import { useRequestsHistoryStore, useRequestStore } from '@/stores';
import { uniquePersistenceKey } from '@/utils/stores'; import { uniquePersistenceKey } from '@/utils/stores';
import { useStorage } from '@vueuse/core'; import { useStorage } from '@vueuse/core';
import { DatabaseBackupIcon } from 'lucide-vue-next';
import { computed } from 'vue'; import { computed } from 'vue';
/* /*
@@ -60,6 +63,10 @@ const {
const lastLog = computed(() => historyStore.lastLog); const lastLog = computed(() => historyStore.lastLog);
const pendingRequestData = computed(() => requestStore.pendingRequestData); const pendingRequestData = computed(() => requestStore.pendingRequestData);
const showTransactionAlert = computed(() => {
return lastLog.value?.request.transactionMode;
});
const handleTabClick = (event: Event) => { const handleTabClick = (event: Event) => {
scrollTabIntoView(event.currentTarget as HTMLElement); scrollTabIntoView(event.currentTarget as HTMLElement);
}; };
@@ -76,11 +83,11 @@ const handleTabClick = (event: Event) => {
class="mt-0 flex h-full flex-col overflow-auto" class="mt-0 flex h-full flex-col overflow-auto"
@update:model-value="tab = $event as string" @update:model-value="tab = $event as string"
> >
<div class="bg-subtle border-b"> <div class="bg-subtle flex items-center justify-between border-b">
<div class="relative"> <div class="relative min-w-0 flex-1">
<div <div
ref="scrollContainer" ref="scrollContainer"
class="scrollbar-hide overflow-x-auto" class="scrollbar-hide flex items-center justify-between overflow-x-auto"
style="scrollbar-width: none; -ms-overflow-style: none" style="scrollbar-width: none; -ms-overflow-style: none"
@scroll="updateScrollMasks" @scroll="updateScrollMasks"
> >
@@ -113,6 +120,20 @@ const handleTabClick = (event: Event) => {
class="from-subtle via-subtle/80 pointer-events-none absolute top-0 right-0 bottom-0 w-8 bg-gradient-to-l to-transparent transition-opacity duration-200" class="from-subtle via-subtle/80 pointer-events-none absolute top-0 right-0 bottom-0 w-8 bg-gradient-to-l to-transparent transition-opacity duration-200"
/> />
</div> </div>
<div class="pr-panel">
<AppTooltipWrapper
v-if="showTransactionAlert"
value="Changes were automatically rolled back for this request"
>
<div class="flex items-center">
<AppBadge variant="outline" class="gap-1">
<DatabaseBackupIcon class="size-3 min-w-3" />
Transaction Mode
</AppBadge>
</div>
</AppTooltipWrapper>
</div>
</div> </div>
<AppTabsContent <AppTabsContent
value="response" value="response"

View File

@@ -141,9 +141,18 @@ export function useHttpClient(): UseHttpClientResult {
const payload = createRelayPayload(request); const payload = createRelayPayload(request);
const formData = convertPayloadToFormData(payload); const formData = convertPayloadToFormData(payload);
const headers: Record<string, string> = {
'Content-Type': 'multipart/form-data',
};
// Add transaction mode header if enabled
if (request.transactionMode) {
headers['X-Nimbus-Transaction-Mode'] = '1';
}
axios axios
.post(url, formData, { .post(url, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers,
// Prevent Axios from parsing JSON automatically as we are handling it manually. // Prevent Axios from parsing JSON automatically as we are handling it manually.
transformResponse: response => response, transformResponse: response => response,
signal: abortController.value?.signal, signal: abortController.value?.signal,

View File

@@ -172,6 +172,8 @@ export function useTabHorizontalScroll(
onMounted(() => { onMounted(() => {
cleanupScrollListeners = setupScrollListeners() ?? null; cleanupScrollListeners = setupScrollListeners() ?? null;
debouncedUpdateMasks();
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@@ -103,6 +103,9 @@ export interface PendingRequest {
/** Whether the request was executed at least once */ /** Whether the request was executed at least once */
wasExecuted?: boolean; wasExecuted?: boolean;
/** Whether to execute the request in transaction mode (rollback on completion) */
transactionMode?: boolean;
} }
export interface Request { export interface Request {
@@ -114,4 +117,5 @@ export interface Request {
payloadType: RequestBodyTypeEnum; payloadType: RequestBodyTypeEnum;
authorization: AuthorizationContract; authorization: AuthorizationContract;
routeDefinition: RouteDefinition; routeDefinition: RouteDefinition;
transactionMode?: boolean;
} }

View File

@@ -174,6 +174,7 @@ export const useRequestBuilderStore = defineStore(
isProcessing: false, isProcessing: false,
wasExecuted: false, wasExecuted: false,
durationInMs: 0, durationInMs: 0,
transactionMode: false,
}; };
// Ensure global headers are synced for the fresh request // Ensure global headers are synced for the fresh request
@@ -258,6 +259,17 @@ export const useRequestBuilderStore = defineStore(
pendingRequestData.value.authorization = authorization; pendingRequestData.value.authorization = authorization;
}; };
/**
* Updates the transaction mode for the current request.
*/
const updateTransactionMode = (transactionMode: boolean) => {
if (!pendingRequestData.value) {
return;
}
pendingRequestData.value.transactionMode = transactionMode;
};
/** /**
* Resets the current request to null state. * Resets the current request to null state.
*/ */
@@ -370,6 +382,7 @@ export const useRequestBuilderStore = defineStore(
} }
: {}), : {}),
wasExecuted: true, wasExecuted: true,
transactionMode: pendingRequestData.value.transactionMode ?? false,
}; };
}; };
@@ -440,6 +453,7 @@ export const useRequestBuilderStore = defineStore(
isProcessing: false, isProcessing: false,
wasExecuted, wasExecuted,
durationInMs: payload.durationInMs ?? 0, durationInMs: payload.durationInMs ?? 0,
transactionMode: false,
}; };
}; };
@@ -476,6 +490,7 @@ export const useRequestBuilderStore = defineStore(
updateRequestBody, updateRequestBody,
updateQueryParameters, updateQueryParameters,
updateAuthorization, updateAuthorization,
updateTransactionMode,
resetRequest, resetRequest,
syncGlobalHeadersWhenApplicable, syncGlobalHeadersWhenApplicable,
getRequestUrl, getRequestUrl,

View File

@@ -67,6 +67,7 @@ export const useRequestStore = defineStore('request', () => {
updateRequestBody: builderStore.updateRequestBody, updateRequestBody: builderStore.updateRequestBody,
updateQueryParameters: builderStore.updateQueryParameters, updateQueryParameters: builderStore.updateQueryParameters,
updateAuthorization: builderStore.updateAuthorization, updateAuthorization: builderStore.updateAuthorization,
updateTransactionMode: builderStore.updateTransactionMode,
getRequestUrl: builderStore.getRequestUrl, getRequestUrl: builderStore.getRequestUrl,
restoreFromHistory: builderStore.restoreFromHistory, restoreFromHistory: builderStore.restoreFromHistory,

View File

@@ -0,0 +1,93 @@
import RequestBuilderEndpointInput from '@/components/domain/Client/Request/RequestBuilderEndpointInput.vue';
import type { PendingRequest } from '@/interfaces/http';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reactive, ref } from 'vue';
/*
* Fixtures.
*/
const mockRequestStore = reactive({
pendingRequestData: ref<PendingRequest | null>(null),
updateRequestEndpoint: vi.fn(),
executeCurrentRequest: vi.fn(),
});
vi.mock('@/stores', async importOriginal => {
const actual = await importOriginal<object>();
return {
...actual,
useRequestStore: () => mockRequestStore,
};
});
vi.mock('@/composables/request/useRouteSegmentSelection', () => ({
useRouteSegmentSelection: () => ({
handleClick: vi.fn(),
}),
}));
/**
* Factory function to create a mounted wrapper with sensible defaults.
*/
const createWrapper = (options = {}): VueWrapper => {
return mount(RequestBuilderEndpointInput, {
...options,
global: {
plugins: [createPinia()],
// @ts-expect-error .global not found in object.
...(options.global || {}),
},
});
};
describe('RequestBuilderEndpointInput', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockRequestStore.pendingRequestData = {
endpoint: '/users/1',
isProcessing: false,
} as unknown as PendingRequest;
});
it('renders the current endpoint', () => {
const wrapper = createWrapper();
const input = wrapper.find('[data-testid="endpoint-input"]');
expect((input.element as HTMLInputElement).value).toBe('/users/1');
});
it('updates the endpoint when input changes', async () => {
const wrapper = createWrapper();
const input = wrapper.find('[data-testid="endpoint-input"]');
await input.setValue('/users/2');
expect(mockRequestStore.updateRequestEndpoint).toHaveBeenCalledWith('/users/2');
});
it('executes the request when the send button is clicked', async () => {
const wrapper = createWrapper();
const button = wrapper.find('button');
await button.trigger('click');
expect(mockRequestStore.executeCurrentRequest).toHaveBeenCalled();
});
it('disables the send button when processing', () => {
if (mockRequestStore.pendingRequestData) {
mockRequestStore.pendingRequestData.isProcessing = true;
}
const wrapper = createWrapper();
const button = wrapper.find('button');
expect(button.attributes('disabled')).toBeDefined();
});
});

View File

@@ -0,0 +1,83 @@
import RequestBuilderMethodSelector from '@/components/domain/Client/Request/RequestBuilderMethodSelector.vue';
import type { PendingRequest } from '@/interfaces/http';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reactive, ref } from 'vue';
/*
* Fixtures.
*/
const mockRequestStore = reactive({
pendingRequestData: ref<PendingRequest | null>(null),
updateRequestMethod: vi.fn(),
});
vi.mock('@/stores', async importOriginal => {
const actual = await importOriginal<object>();
return {
...actual,
useRequestStore: () => mockRequestStore,
};
});
/**
* Factory function to create a mounted wrapper with sensible defaults.
*/
const createWrapper = (options = {}): VueWrapper => {
return mount(RequestBuilderMethodSelector, {
...options,
global: {
plugins: [createPinia()],
// @ts-expect-error .global not found in object.
...(options.global || {}),
},
});
};
describe('RequestBuilderMethodSelector', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockRequestStore.pendingRequestData = {
method: 'GET',
supportedRoutes: [
{ method: 'GET', endpoint: '/test' },
{ method: 'POST', endpoint: '/test' },
],
} as unknown as PendingRequest;
});
it('renders the current method', () => {
const wrapper = createWrapper();
expect(wrapper.text()).toContain('GET');
});
it('updates the method when a new one is selected', async () => {
const wrapper = createWrapper();
// Simulate selection (depends on how AppSelect works, but we can verify the computed property)
// Since we are unit testing the component's internal logic:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.method = 'POST';
expect(mockRequestStore.updateRequestMethod).toHaveBeenCalledWith('POST');
});
it('identifies supported and unsupported methods correctly', () => {
const wrapper = createWrapper();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.currentRouteSupportedMethods).toContain('GET');
expect(vm.currentRouteSupportedMethods).toContain('POST');
expect(vm.currentRouteUnsupportedMethods).toContain('PUT');
expect(vm.currentRouteUnsupportedMethods).toContain('PATCH');
expect(vm.currentRouteUnsupportedMethods).toContain('DELETE');
});
});

View File

@@ -0,0 +1,86 @@
import RequestBuilderOptionsMenu from '@/components/domain/Client/Request/RequestBuilderOptionsMenu.vue';
import type { PendingRequest } from '@/interfaces/http';
import type { VueWrapper } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reactive, ref } from 'vue';
/*
* Fixtures.
*/
const mockRequestStore = reactive({
pendingRequestData: ref<PendingRequest | null>(null),
updateTransactionMode: vi.fn(),
});
const mockConfigStore = reactive({
apiUrl: 'http://localhost',
activeApplication: 'main',
appBasePath: '/nimbus',
});
const mockHistoryStore = reactive({
lastLog: null,
});
vi.mock('@/stores', async importOriginal => {
const actual = await importOriginal<object>();
return {
...actual,
useRequestStore: () => mockRequestStore,
useConfigStore: () => mockConfigStore,
useRequestsHistoryStore: () => mockHistoryStore,
};
});
/**
* Factory function to create a mounted wrapper with sensible defaults.
*/
const createWrapper = (options = {}): VueWrapper => {
return mount(RequestBuilderOptionsMenu, {
...options,
global: {
plugins: [createPinia()],
stubs: {
CurlExportDialog: true,
ShareableLinkDialog: true,
AppPopover: true,
AppPopoverContent: true,
AppPopoverTrigger: true,
},
// @ts-expect-error .global not found in object.
...(options.global || {}),
},
});
};
describe('RequestBuilderOptionsMenu', () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
mockRequestStore.pendingRequestData = {
transactionMode: false,
} as unknown as PendingRequest;
});
it('renders the options button', () => {
const wrapper = createWrapper();
expect(wrapper.find('[data-testid="request-options-button"]').exists()).toBe(
true,
);
});
it('updates transaction mode via computed property', async () => {
const wrapper = createWrapper();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.transactionMode = true;
expect(mockRequestStore.updateTransactionMode).toHaveBeenCalledWith(true);
});
});

View File

@@ -69,6 +69,7 @@ const pendingRequestToRequestLogEntry = function (request: PendingRequest): Requ
payloadType: request.payloadType, payloadType: request.payloadType,
authorization: { ...request.authorization }, authorization: { ...request.authorization },
routeDefinition: { ...request.routeDefinition }, routeDefinition: { ...request.routeDefinition },
transactionMode: request.transactionMode,
}; };
}; };

View File

@@ -78,9 +78,16 @@ class RequestRelayAction
$requestBody = []; $requestBody = [];
} }
$headers = $requestRelayData->headers;
// Add transaction mode header if enabled
if ($requestRelayData->transactionMode) {
$headers['x-nimbus-transaction-mode'] = '1';
}
// SSL verification is disabled to support development environments with self-signed certificates. // SSL verification is disabled to support development environments with self-signed certificates.
return Http::withoutVerifying() return Http::withoutVerifying()
->withHeaders($requestRelayData->headers) ->withHeaders($headers)
->withQueryParameters($queryParameters) ->withQueryParameters($queryParameters)
->when( ->when(
$requestBody !== [], $requestBody !== [],

View File

@@ -24,6 +24,7 @@ readonly class RequestRelayData
public array|string $body, public array|string $body,
public ParameterBag $cookies, public ParameterBag $cookies,
public array $queryParameters = [], public array $queryParameters = [],
public bool $transactionMode = false,
) {} ) {}
public static function fromRelayApiRequest(NimbusRelayRequest $nimbusRelayRequest): self public static function fromRelayApiRequest(NimbusRelayRequest $nimbusRelayRequest): self
@@ -58,6 +59,8 @@ readonly class RequestRelayData
'queryParameters' => $queryParameters, 'queryParameters' => $queryParameters,
] = self::extractAndRemoveQueryParametersFromEndpoint($data['endpoint']); ] = self::extractAndRemoveQueryParametersFromEndpoint($data['endpoint']);
$transactionMode = $nimbusRelayRequest->header('X-Nimbus-Transaction-Mode') === '1';
return new self( return new self(
method: strtolower($data['method']), method: strtolower($data['method']),
endpoint: $endpoint, endpoint: $endpoint,
@@ -71,6 +74,7 @@ readonly class RequestRelayData
body: $nimbusRelayRequest->getBody(), body: $nimbusRelayRequest->getBody(),
cookies: $nimbusRelayRequest->cookies, cookies: $nimbusRelayRequest->cookies,
queryParameters: $queryParameters, queryParameters: $queryParameters,
transactionMode: $transactionMode,
); );
} }

View File

@@ -2,6 +2,7 @@
namespace Sunchayn\Nimbus; namespace Sunchayn\Nimbus;
use Illuminate\Routing\Events\RouteMatched;
use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\Package;
use Spatie\LaravelPackageTools\PackageServiceProvider; use Spatie\LaravelPackageTools\PackageServiceProvider;
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService; use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
@@ -42,7 +43,7 @@ class NimbusServiceProvider extends PackageServiceProvider
$this->app->singleton( $this->app->singleton(
IgnoredRoutesService::class, IgnoredRoutesService::class,
fn (): \Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService => new IgnoredRoutesService, fn (): IgnoredRoutesService => new IgnoredRoutesService,
); );
} }
@@ -55,6 +56,19 @@ class NimbusServiceProvider extends PackageServiceProvider
parent::boot(); parent::boot();
$this->tagAlongsideLaravelAssets(); $this->tagAlongsideLaravelAssets();
// Handle transaction mode for requests
$this->app->get('events')->listen(RouteMatched::class, function (RouteMatched $routeMatched): void {
if ($routeMatched->request->header('X-Nimbus-Transaction-Mode') === '1') {
app('db')->beginTransaction();
app()->terminating(function (): void {
if (app('db')->transactionLevel() > 0) {
app('db')->rollBack();
}
});
}
});
} }
/** /**

View File

@@ -471,6 +471,55 @@ class RequestRelayActionFunctionalTest extends TestCase
); );
} }
public function test_it_relays_transaction_mode_header(): void
{
// Arrange
$requestData = new RequestRelayData(
method: 'POST',
endpoint: self::ENDPOINT,
authorization: AuthorizationCredentials::none(),
headers: [
'Content-Type' => 'application/json',
],
body: ['test' => 'data'],
cookies: new ParameterBag,
transactionMode: true,
);
// Anticipate
Http::fake(function (Request $request) {
return Http::response([
'receivedHeaders' => $request->headers(),
], 200);
});
$this->mockAuthorizationHandler();
$requestRelayAction = resolve(RequestRelayAction::class);
// Act
$response = $requestRelayAction->execute($requestData);
// Assert
$this->assertEquals(200, $response->statusCode);
$this->assertArrayHasKey(
'x-nimbus-transaction-mode',
$response->body->body['receivedHeaders'],
'The transaction mode header should be present in the relayed request.',
);
$this->assertEquals(
'1',
$response->body->body['receivedHeaders']['x-nimbus-transaction-mode'][0],
'The transaction mode header should have the value "1".',
);
}
/* /*
* Helpers. * Helpers.
*/ */

View File

@@ -57,6 +57,7 @@ class RequestRelayDataUnitTest extends TestCase
); );
$mockRequest->shouldReceive('getBody')->andReturn($body); $mockRequest->shouldReceive('getBody')->andReturn($body);
$mockRequest->shouldReceive('header')->with('X-Nimbus-Transaction-Mode')->andReturnNull();
// Act // Act
@@ -89,6 +90,48 @@ class RequestRelayDataUnitTest extends TestCase
$expectedParameters, $expectedParameters,
$result->queryParameters, $result->queryParameters,
); );
$this->assertFalse($result->transactionMode);
}
public function test_it_extracts_transaction_mode_from_header(): void
{
// Arrange
$mockRequest = Mockery::mock(NimbusRelayRequest::class);
$mockRequest->shouldReceive('userAgent')->andReturn('::dummy_user_agent::');
$mockRequest->shouldReceive('host')->andReturn('::dummy_host::');
$mockRequest->cookies = new InputBag;
// Anticipate
$mockRequest
->shouldReceive('validated')
->andReturn(
[
'method' => 'POST',
'endpoint' => '/api/test',
'authorization' => [
'type' => AuthorizationTypeEnum::Bearer->value,
'value' => 'foobar',
],
'body' => [],
],
);
$mockRequest->shouldReceive('getBody')->andReturn([]);
$mockRequest->shouldReceive('header')->with('X-Nimbus-Transaction-Mode')->andReturn('1');
// Act
$result = RequestRelayData::fromRelayApiRequest($mockRequest);
// Assert
$this->assertTrue($result->transactionMode);
} }
public static function relayRequestDataDataProvider(): Generator public static function relayRequestDataDataProvider(): Generator

View File

@@ -250,4 +250,43 @@ class NimbusRelayTest extends TestCase
'timestamp', 'timestamp',
]); ]);
} }
public function test_it_rolls_back_database_changes_when_transaction_mode_is_enabled(): void
{
// Arrange
// Create a test table for this test
app('db')->statement('CREATE TABLE IF NOT EXISTS test_users (id INTEGER PRIMARY KEY, name TEXT)');
Route::post('/test-transaction-rollback', function () {
app('db')->table('test_users')->insert(['name' => 'Test User']);
return response()->json(['message' => 'Users Count: '.app('db')->table('test_users')->count()]);
})->name('test-transaction-rollback');
// Act
$response = $this->postJson(
route('test-transaction-rollback'),
[],
['X-Nimbus-Transaction-Mode' => '1']
);
// Assert
$response->assertStatus(200);
$response->assertJson([
'message' => 'Users Count: 1',
]);
// Verify the database change was rolled back
$userCount = app('db')->table('test_users')->count();
$this->assertEquals(0, $userCount, 'Database changes should be rolled back when transaction mode is enabled');
// Cleanup
app('db')->statement('DROP TABLE IF EXISTS test_users');
}
} }

View File

@@ -47,5 +47,6 @@
<server name="APP_KEY" value="base64:N93kQtRXBdanjTYl9XsDGnhiy5diPi+5G3diY/Qy8ZM="/> <server name="APP_KEY" value="base64:N93kQtRXBdanjTYl9XsDGnhiy5diPi+5G3diY/Qy8ZM="/>
<server name="BCRYPT_ROUNDS" value="4"/> <server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/> <server name="CACHE_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="testing"/>
</php> </php>
</phpunit> </phpunit>

View File

@@ -28,6 +28,7 @@ This guide covers everything you need to know about using Nimbus to test and exp
- [Global Headers](#global-headers) - [Global Headers](#global-headers)
- [Value Generators](#value-generators) - [Value Generators](#value-generators)
- [Auto-Fill Payloads](#auto-fill-payloads) - [Auto-Fill Payloads](#auto-fill-payloads)
- [Transaction Mode](#transaction-mode)
- [Export to cURL](#export-to-curl) - [Export to cURL](#export-to-curl)
- [Shareable Links](#shareable-links) - [Shareable Links](#shareable-links)
- [Configuration](#configuration) - [Configuration](#configuration)
@@ -332,13 +333,28 @@ Generate realistic values for headers and parameters on-demand.
- Dates (various formats). - Dates (various formats).
- Phone numbers. - Phone numbers.
- URLs. - URLs.
- URLs.
- And more... - And more...
### Transaction Mode
Transaction Mode allows you to execute requests without affecting your database. This is particularly useful for testing destructive operations (like creating, updating, or deleting records) without having to manually clean up your test data.
**How it works:**
1. Click the **Request Options** (sparkles) icon in the endpoint bar.
2. Toggle **Transaction Mode** to **ON**.
3. Execute your request.
4. Nimbus wraps the entire request in a database transaction and automatically rolls it back once the request is complete.
![Transaction Mode](./assets/transaction-mode.png)
**Note:** Only database operations are rolled back. External side effects like sending emails, making external API calls, or file system changes will still occur.
### Export to cURL ### Export to cURL
Export configured requests as cURL commands for use in terminals or scripts. Export configured requests as cURL commands for use in terminals or scripts.
![Click on Export](./assets/click-on-export.png) ![Export to curl](./assets/export-to-curl.png)
**How to export:** **How to export:**
1. Configure your request completely 1. Configure your request completely

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB