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:
@@ -44,7 +44,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
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,
|
||||
)
|
||||
"
|
||||
|
||||
@@ -3,37 +3,11 @@
|
||||
* @component RequestBuilderEndpoint
|
||||
* @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 { CodeXml, CornerDownLeftIcon, Link2, SparklesIcon } from 'lucide-vue-next';
|
||||
import { computed, type HTMLAttributes, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import CurlExportDialog from './CurlExportDialog.vue';
|
||||
import ShareableLinkDialog from './ShareableLinkDialog.vue';
|
||||
import { type HTMLAttributes } from 'vue';
|
||||
import RequestBuilderEndpointInput from './RequestBuilderEndpointInput.vue';
|
||||
import RequestBuilderMethodSelector from './RequestBuilderMethodSelector.vue';
|
||||
import RequestBuilderOptionsMenu from './RequestBuilderOptionsMenu.vue';
|
||||
|
||||
/*
|
||||
* Types & Interfaces.
|
||||
@@ -48,240 +22,16 @@ export interface 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>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex', props.class)" data-testid="request-builder-endpoint">
|
||||
<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>
|
||||
<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>
|
||||
<RequestBuilderMethodSelector />
|
||||
|
||||
<CurlExportDialog
|
||||
v-model:open="showCurlDialog"
|
||||
:command="curlCommand"
|
||||
:has-special-auth="hasSpecialAuth"
|
||||
/>
|
||||
|
||||
<ShareableLinkDialog v-model:open="showShareableLinkDialog" :link="shareableLink" />
|
||||
<RequestBuilderEndpointInput>
|
||||
<template #options-menu>
|
||||
<RequestBuilderOptionsMenu />
|
||||
</template>
|
||||
</RequestBuilderEndpointInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -3,12 +3,14 @@
|
||||
* @component ResponseViewerResponse
|
||||
* @description Renders the successful response details, including body, headers, and cookies.
|
||||
*/
|
||||
import { AppBadge } from '@/components/base/badge';
|
||||
import {
|
||||
AppTabs,
|
||||
AppTabsContent,
|
||||
AppTabsList,
|
||||
AppTabsTrigger,
|
||||
} from '@/components/base/tabs';
|
||||
import { AppTooltipWrapper } from '@/components/base/tooltip';
|
||||
import ResponseBody from '@/components/domain/Client/Response/ResponseBody/ResponseBody.vue';
|
||||
import ResponseDumpAndDie from '@/components/domain/Client/Response/ResponseBody/ResponseDumpAndDie.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 { uniquePersistenceKey } from '@/utils/stores';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { DatabaseBackupIcon } from 'lucide-vue-next';
|
||||
import { computed } from 'vue';
|
||||
|
||||
/*
|
||||
@@ -60,6 +63,10 @@ const {
|
||||
const lastLog = computed(() => historyStore.lastLog);
|
||||
const pendingRequestData = computed(() => requestStore.pendingRequestData);
|
||||
|
||||
const showTransactionAlert = computed(() => {
|
||||
return lastLog.value?.request.transactionMode;
|
||||
});
|
||||
|
||||
const handleTabClick = (event: Event) => {
|
||||
scrollTabIntoView(event.currentTarget as HTMLElement);
|
||||
};
|
||||
@@ -76,11 +83,11 @@ const handleTabClick = (event: Event) => {
|
||||
class="mt-0 flex h-full flex-col overflow-auto"
|
||||
@update:model-value="tab = $event as string"
|
||||
>
|
||||
<div class="bg-subtle border-b">
|
||||
<div class="relative">
|
||||
<div class="bg-subtle flex items-center justify-between border-b">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<div
|
||||
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"
|
||||
@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"
|
||||
/>
|
||||
</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>
|
||||
<AppTabsContent
|
||||
value="response"
|
||||
|
||||
@@ -141,9 +141,18 @@ export function useHttpClient(): UseHttpClientResult {
|
||||
const payload = createRelayPayload(request);
|
||||
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
|
||||
.post(url, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
headers,
|
||||
// Prevent Axios from parsing JSON automatically as we are handling it manually.
|
||||
transformResponse: response => response,
|
||||
signal: abortController.value?.signal,
|
||||
|
||||
@@ -172,6 +172,8 @@ export function useTabHorizontalScroll(
|
||||
|
||||
onMounted(() => {
|
||||
cleanupScrollListeners = setupScrollListeners() ?? null;
|
||||
|
||||
debouncedUpdateMasks();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -103,6 +103,9 @@ export interface PendingRequest {
|
||||
|
||||
/** Whether the request was executed at least once */
|
||||
wasExecuted?: boolean;
|
||||
|
||||
/** Whether to execute the request in transaction mode (rollback on completion) */
|
||||
transactionMode?: boolean;
|
||||
}
|
||||
|
||||
export interface Request {
|
||||
@@ -114,4 +117,5 @@ export interface Request {
|
||||
payloadType: RequestBodyTypeEnum;
|
||||
authorization: AuthorizationContract;
|
||||
routeDefinition: RouteDefinition;
|
||||
transactionMode?: boolean;
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export const useRequestBuilderStore = defineStore(
|
||||
isProcessing: false,
|
||||
wasExecuted: false,
|
||||
durationInMs: 0,
|
||||
transactionMode: false,
|
||||
};
|
||||
|
||||
// Ensure global headers are synced for the fresh request
|
||||
@@ -258,6 +259,17 @@ export const useRequestBuilderStore = defineStore(
|
||||
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.
|
||||
*/
|
||||
@@ -370,6 +382,7 @@ export const useRequestBuilderStore = defineStore(
|
||||
}
|
||||
: {}),
|
||||
wasExecuted: true,
|
||||
transactionMode: pendingRequestData.value.transactionMode ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -440,6 +453,7 @@ export const useRequestBuilderStore = defineStore(
|
||||
isProcessing: false,
|
||||
wasExecuted,
|
||||
durationInMs: payload.durationInMs ?? 0,
|
||||
transactionMode: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -476,6 +490,7 @@ export const useRequestBuilderStore = defineStore(
|
||||
updateRequestBody,
|
||||
updateQueryParameters,
|
||||
updateAuthorization,
|
||||
updateTransactionMode,
|
||||
resetRequest,
|
||||
syncGlobalHeadersWhenApplicable,
|
||||
getRequestUrl,
|
||||
|
||||
@@ -67,6 +67,7 @@ export const useRequestStore = defineStore('request', () => {
|
||||
updateRequestBody: builderStore.updateRequestBody,
|
||||
updateQueryParameters: builderStore.updateQueryParameters,
|
||||
updateAuthorization: builderStore.updateAuthorization,
|
||||
updateTransactionMode: builderStore.updateTransactionMode,
|
||||
getRequestUrl: builderStore.getRequestUrl,
|
||||
restoreFromHistory: builderStore.restoreFromHistory,
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,7 @@ const pendingRequestToRequestLogEntry = function (request: PendingRequest): Requ
|
||||
payloadType: request.payloadType,
|
||||
authorization: { ...request.authorization },
|
||||
routeDefinition: { ...request.routeDefinition },
|
||||
transactionMode: request.transactionMode,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -78,9 +78,16 @@ class RequestRelayAction
|
||||
$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.
|
||||
return Http::withoutVerifying()
|
||||
->withHeaders($requestRelayData->headers)
|
||||
->withHeaders($headers)
|
||||
->withQueryParameters($queryParameters)
|
||||
->when(
|
||||
$requestBody !== [],
|
||||
|
||||
@@ -24,6 +24,7 @@ readonly class RequestRelayData
|
||||
public array|string $body,
|
||||
public ParameterBag $cookies,
|
||||
public array $queryParameters = [],
|
||||
public bool $transactionMode = false,
|
||||
) {}
|
||||
|
||||
public static function fromRelayApiRequest(NimbusRelayRequest $nimbusRelayRequest): self
|
||||
@@ -58,6 +59,8 @@ readonly class RequestRelayData
|
||||
'queryParameters' => $queryParameters,
|
||||
] = self::extractAndRemoveQueryParametersFromEndpoint($data['endpoint']);
|
||||
|
||||
$transactionMode = $nimbusRelayRequest->header('X-Nimbus-Transaction-Mode') === '1';
|
||||
|
||||
return new self(
|
||||
method: strtolower($data['method']),
|
||||
endpoint: $endpoint,
|
||||
@@ -71,6 +74,7 @@ readonly class RequestRelayData
|
||||
body: $nimbusRelayRequest->getBody(),
|
||||
cookies: $nimbusRelayRequest->cookies,
|
||||
queryParameters: $queryParameters,
|
||||
transactionMode: $transactionMode,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Sunchayn\Nimbus;
|
||||
|
||||
use Illuminate\Routing\Events\RouteMatched;
|
||||
use Spatie\LaravelPackageTools\Package;
|
||||
use Spatie\LaravelPackageTools\PackageServiceProvider;
|
||||
use Sunchayn\Nimbus\Modules\Routes\Services\IgnoredRoutesService;
|
||||
@@ -42,7 +43,7 @@ class NimbusServiceProvider extends PackageServiceProvider
|
||||
|
||||
$this->app->singleton(
|
||||
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();
|
||||
|
||||
$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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -57,6 +57,7 @@ class RequestRelayDataUnitTest extends TestCase
|
||||
);
|
||||
|
||||
$mockRequest->shouldReceive('getBody')->andReturn($body);
|
||||
$mockRequest->shouldReceive('header')->with('X-Nimbus-Transaction-Mode')->andReturnNull();
|
||||
|
||||
// Act
|
||||
|
||||
@@ -89,6 +90,48 @@ class RequestRelayDataUnitTest extends TestCase
|
||||
$expectedParameters,
|
||||
$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
|
||||
|
||||
@@ -250,4 +250,43 @@ class NimbusRelayTest extends TestCase
|
||||
'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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,5 +47,6 @@
|
||||
<server name="APP_KEY" value="base64:N93kQtRXBdanjTYl9XsDGnhiy5diPi+5G3diY/Qy8ZM="/>
|
||||
<server name="BCRYPT_ROUNDS" value="4"/>
|
||||
<server name="CACHE_DRIVER" value="array"/>
|
||||
<env name="DB_CONNECTION" value="testing"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
@@ -28,6 +28,7 @@ This guide covers everything you need to know about using Nimbus to test and exp
|
||||
- [Global Headers](#global-headers)
|
||||
- [Value Generators](#value-generators)
|
||||
- [Auto-Fill Payloads](#auto-fill-payloads)
|
||||
- [Transaction Mode](#transaction-mode)
|
||||
- [Export to cURL](#export-to-curl)
|
||||
- [Shareable Links](#shareable-links)
|
||||
- [Configuration](#configuration)
|
||||
@@ -332,13 +333,28 @@ Generate realistic values for headers and parameters on-demand.
|
||||
- Dates (various formats).
|
||||
- Phone numbers.
|
||||
- URLs.
|
||||
- URLs.
|
||||
- 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.
|
||||
|
||||

|
||||
|
||||
**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 configured requests as cURL commands for use in terminals or scripts.
|
||||
|
||||

|
||||

|
||||
|
||||
**How to export:**
|
||||
1. Configure your request completely
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
BIN
wiki/user-guide/assets/export-to-curl.png
Normal file
BIN
wiki/user-guide/assets/export-to-curl.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
wiki/user-guide/assets/transaction-mode.png
Normal file
BIN
wiki/user-guide/assets/transaction-mode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Reference in New Issue
Block a user