feat(export): add shareable links (#41)
* feat(export): add shareable links * chore: reconfigure PW * test: fix namespace * style: apply prettier * chore: reduce workers count in CI for PW tests are running slower (to the point some time out) and flaky * fix: initialize pending request from store immediately * chore: apply rector
This commit is contained in:
@@ -38,6 +38,9 @@
|
||||
"rector/rector": "^1.2 || ^2.2",
|
||||
"spatie/laravel-data": "^4.18"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-zlib": "Required for shareable links feature."
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sunchayn\\Nimbus\\": "src/"
|
||||
|
||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"codemirror-json-schema": "^0.8.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"pako": "^2.1.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"pretty-bytes": "^7.0.1",
|
||||
@@ -35,6 +36,7 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.43.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
@@ -2370,6 +2372,13 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -7800,6 +7809,12 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"build": "npm run type:check && npm run vite -- build && cp -a ./resources/dist-static/. ./resources/dist",
|
||||
"build:dev": "npm run vite -- build --mode=development && cp -a ./resources/dist-static/. ./resources/dist",
|
||||
"dev": "npm run vite -- dev",
|
||||
"test": "vitest --config resources/js/tests/vitest.config.js",
|
||||
"test": "vitest --config resources/js/tests/vitest.config.ts",
|
||||
"test:ui": "npm run test -- --ui",
|
||||
"test:run": "npm run test -- run",
|
||||
"test:coverage": "npm run test:run --coverage",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/node": "^22.13.5",
|
||||
"@types/pako": "^2.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.43.0",
|
||||
"@typescript-eslint/parser": "^8.43.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
@@ -73,6 +74,7 @@
|
||||
"codemirror-json-schema": "^0.8.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"pako": "^2.1.0",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"pretty-bytes": "^7.0.1",
|
||||
|
||||
@@ -4,10 +4,12 @@ import { AppSonner } from '@/components/base/sonner';
|
||||
import ValueGenerator from '@/components/common/ValueGenerator/ValueGenerator.vue';
|
||||
import ScreenNavigationSidebar from '@/components/layout/ScreenNavigationSidebar.vue';
|
||||
import { useSettingsStore } from '@/stores';
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { onMounted, provide, ref, watch } from 'vue';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const currentTheme = ref('');
|
||||
|
||||
function applyTheme(preference: 'light' | 'dark' | 'system') {
|
||||
if (preference === 'system') {
|
||||
const isDark =
|
||||
@@ -17,6 +19,8 @@ function applyTheme(preference: 'light' | 'dark' | 'system') {
|
||||
preference = isDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
currentTheme.value = preference;
|
||||
|
||||
if (preference === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
@@ -61,6 +65,8 @@ watch(
|
||||
applyTheme(theme);
|
||||
},
|
||||
);
|
||||
|
||||
provide('theme', currentTheme);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { inject } from 'vue';
|
||||
import type { ToasterProps } from 'vue-sonner';
|
||||
import { Toaster as Sonner } from 'vue-sonner';
|
||||
|
||||
@@ -7,6 +8,8 @@ defineOptions({
|
||||
});
|
||||
|
||||
const props = defineProps<ToasterProps>();
|
||||
|
||||
const appTheme = inject<'dark' | 'light'>('theme');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -18,5 +21,6 @@ const props = defineProps<ToasterProps>();
|
||||
'--normal-text': 'var(--color-popover-foreground)',
|
||||
'--normal-border': 'var(--color-border)',
|
||||
}"
|
||||
:theme="appTheme"
|
||||
/>
|
||||
</template>
|
||||
@@ -1 +1 @@
|
||||
export { default as AppSonner } from './Sonner.vue';
|
||||
export { default as AppSonner } from './AppSonner.vue';
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
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,
|
||||
@@ -10,15 +18,17 @@ import {
|
||||
AppSelectTrigger,
|
||||
AppSelectValue,
|
||||
} from '@/components/base/select';
|
||||
import AppTooltipWrapper from '@/components/base/tooltip/AppTooltipWrapper.vue';
|
||||
import { useRouteSegmentSelection } from '@/composables/request/useRouteSegmentSelection';
|
||||
import { RouteDefinition } from '@/interfaces/routes/routes';
|
||||
import { useConfigStore, useRequestStore } from '@/stores';
|
||||
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 } from 'lucide-vue-next';
|
||||
import { CodeXml, CornerDownLeftIcon, Link2, SparklesIcon } from 'lucide-vue-next';
|
||||
import { computed, HTMLAttributes, ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import CurlExportDialog from './CurlExportDialog.vue';
|
||||
import ShareableLinkDialog from './ShareableLinkDialog.vue';
|
||||
|
||||
interface RequestBuilderEndpointProps {
|
||||
class?: HTMLAttributes['class'];
|
||||
@@ -34,6 +44,7 @@ const availableMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
|
||||
const requestStore = useRequestStore();
|
||||
const configStore = useConfigStore();
|
||||
const historyStore = useRequestsHistoryStore();
|
||||
|
||||
/*
|
||||
* State.
|
||||
@@ -43,6 +54,9 @@ const showCurlDialog = ref(false);
|
||||
const curlCommand = ref('');
|
||||
const hasSpecialAuth = ref(false);
|
||||
|
||||
const showShareableLinkDialog = ref(false);
|
||||
const shareableLink = ref('');
|
||||
|
||||
/*
|
||||
* Computed.
|
||||
*/
|
||||
@@ -121,6 +135,38 @@ const populateCurlCommandExporterDialog = () => {
|
||||
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>
|
||||
@@ -177,16 +223,40 @@ const populateCurlCommandExporterDialog = () => {
|
||||
<CornerDownLeftIcon class="size-3 px-0" />
|
||||
)
|
||||
</AppButton>
|
||||
<AppTooltipWrapper value="Export cURL">
|
||||
<AppButton
|
||||
variant="outline"
|
||||
size="xs"
|
||||
:disabled="!pendingRequestData"
|
||||
@click="populateCurlCommandExporterDialog"
|
||||
>
|
||||
<CodeXml />
|
||||
</AppButton>
|
||||
</AppTooltipWrapper>
|
||||
<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>
|
||||
@@ -196,4 +266,6 @@ const populateCurlCommandExporterDialog = () => {
|
||||
:command="curlCommand"
|
||||
:has-special-auth="hasSpecialAuth"
|
||||
/>
|
||||
|
||||
<ShareableLinkDialog v-model:open="showShareableLinkDialog" :link="shareableLink" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { AppButton } from '@/components/base/button';
|
||||
import {
|
||||
AppDialog,
|
||||
AppDialogContent,
|
||||
AppDialogDescription,
|
||||
AppDialogHeader,
|
||||
AppDialogTitle,
|
||||
} from '@/components/base/dialog';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { Check, Copy, Link2 } from 'lucide-vue-next';
|
||||
|
||||
interface ShareableLinkDialogProps {
|
||||
open: boolean;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const props = defineProps<ShareableLinkDialogProps>();
|
||||
const emits = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
const { copy, copied } = useClipboard();
|
||||
|
||||
const copyLink = () => {
|
||||
copy(props.link);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
emits('update:open', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :open="open" @update:open="emits('update:open', $event)">
|
||||
<AppDialogContent
|
||||
class="flex max-h-[90vh] max-w-2xl flex-col overflow-hidden sm:max-w-2xl"
|
||||
>
|
||||
<AppDialogHeader>
|
||||
<AppDialogTitle class="flex items-center gap-2">
|
||||
<Link2 class="size-5" />
|
||||
Shareable Link
|
||||
</AppDialogTitle>
|
||||
<AppDialogDescription>
|
||||
Share this link with your teammates to restore the exact request state
|
||||
and response.
|
||||
</AppDialogDescription>
|
||||
</AppDialogHeader>
|
||||
|
||||
<div class="flex flex-1 flex-col space-y-4 overflow-hidden">
|
||||
<div class="rounded-md bg-indigo-50 p-3 dark:bg-indigo-900/20">
|
||||
<p class="text-sm text-indigo-800 dark:text-indigo-200">
|
||||
This link contains the full request configuration and the latest
|
||||
response. Anyone with this link can restore the exact state in
|
||||
their Nimbus instance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col space-y-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Link
|
||||
</h4>
|
||||
<AppButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex items-center gap-2"
|
||||
@click="copyLink"
|
||||
>
|
||||
<Check v-if="copied" class="h-4 w-4" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
class="bg-subtle-background text-foreground flex-1 overflow-auto rounded-md border p-4 font-mono text-sm leading-relaxed break-all whitespace-break-spaces"
|
||||
data-testid="shareable-link-content"
|
||||
>{{ link }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end border-t pt-4 dark:border-gray-700">
|
||||
<AppButton variant="outline" @click="closeDialog">Close</AppButton>
|
||||
</div>
|
||||
</AppDialogContent>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -158,7 +158,6 @@ const resetClearConfirmation = () => {
|
||||
<small class="text-subtle text-xs" :title="absoluteTime">
|
||||
{{ readableTime }}
|
||||
</small>
|
||||
|
||||
<HistoryIcon class="size-3" />
|
||||
</AppButton>
|
||||
</AppDropdownMenuTrigger>
|
||||
|
||||
@@ -5,11 +5,12 @@ import ResponseStatusCode from '@/components/domain/Client/Response/ResponseStat
|
||||
import { PendingRequest, STATUS } from '@/interfaces/http';
|
||||
import { useRequestsHistoryStore, useRequestStore } from '@/stores';
|
||||
import { cn } from '@/utils/ui';
|
||||
import { RefreshCwOffIcon } from 'lucide-vue-next';
|
||||
import { Import, RefreshCwOffIcon } from 'lucide-vue-next';
|
||||
import prettyBytes from 'pretty-bytes';
|
||||
import prettyMs from 'pretty-ms';
|
||||
import { PrimitiveProps } from 'reka-ui';
|
||||
import { computed, ComputedRef, HTMLAttributes } from 'vue';
|
||||
import AppTooltipWrapper from '../../../../base/tooltip/AppTooltipWrapper.vue';
|
||||
|
||||
/*
|
||||
* Types & interfaces.
|
||||
@@ -75,7 +76,6 @@ const duration = computed(() => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
* Actions.
|
||||
*/
|
||||
@@ -83,42 +83,55 @@ const duration = computed(() => {
|
||||
const cancelRequest = () => {
|
||||
requestStore.cancelCurrentRequest();
|
||||
};
|
||||
|
||||
const isImportedFromShare = computed(() => lastLog.value?.importedFromShare === true);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('h-toolbar relative flex items-center justify-between p-2', props.class)
|
||||
"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<ResponseStatusCode
|
||||
:status="status"
|
||||
:response="
|
||||
!pendingRequestData?.isProcessing ? lastLog?.response : undefined
|
||||
"
|
||||
/>
|
||||
<div class="w-8 border-b border-zinc-200"></div>
|
||||
<span class="text-xs">
|
||||
<span data-testid="response-status-duration">{{ duration }}</span>
|
||||
<template v-if="!pendingRequestData?.isProcessing">
|
||||
<span class="text-color-muted mx-1 text-xs">/</span>
|
||||
<span data-testid="response-status-size">{{ size }}</span>
|
||||
</template>
|
||||
</span>
|
||||
<div :class="cn('h-toolbar flex', props.class)">
|
||||
<div class="relative flex h-full flex-1 items-center justify-between p-2">
|
||||
<div class="flex w-full items-center justify-between gap-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<ResponseStatusCode
|
||||
:status="status"
|
||||
:response="
|
||||
!pendingRequestData?.isProcessing
|
||||
? lastLog?.response
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
<div class="w-8 border-b border-zinc-200"></div>
|
||||
<span class="text-xs">
|
||||
<span data-testid="response-status-duration">{{ duration }}</span>
|
||||
<template v-if="!pendingRequestData?.isProcessing">
|
||||
<span class="text-color-muted mx-1 text-xs">/</span>
|
||||
<span data-testid="response-status-size">{{ size }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!pendingRequestData?.isProcessing" class="flex items-center">
|
||||
<RequestHistory />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!pendingRequestData?.isProcessing" class="flex items-center">
|
||||
<RequestHistory />
|
||||
<div v-if="pendingRequestData?.isProcessing">
|
||||
<AppButton variant="outline" size="xs" @click="cancelRequest">
|
||||
<RefreshCwOffIcon />
|
||||
Cancel
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingRequestData?.isProcessing">
|
||||
<AppButton variant="outline" size="xs" @click="cancelRequest">
|
||||
<RefreshCwOffIcon />
|
||||
Cancel
|
||||
</AppButton>
|
||||
</div>
|
||||
<AppTooltipWrapper
|
||||
v-if="isImportedFromShare"
|
||||
value="This response was imported from a shareable link"
|
||||
>
|
||||
<div
|
||||
class="flex h-full items-center gap-1 rounded-none border-indigo-500/50 bg-indigo-500/5 p-3.5 text-xs text-indigo-600 dark:text-indigo-400"
|
||||
data-testid="imported-badge"
|
||||
>
|
||||
<Import class="size-4" />
|
||||
</div>
|
||||
</AppTooltipWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { authorizationConfig } from '@/config';
|
||||
import { AuthorizationContract } from '@/interfaces/auth/authorization';
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { useRequestStore } from '@/stores';
|
||||
import { readonly, ref, watch } from 'vue';
|
||||
import { computed, readonly, watch } from 'vue';
|
||||
|
||||
/**
|
||||
* Default authorization states for each type
|
||||
@@ -46,18 +46,28 @@ export function useRequestAuthorization() {
|
||||
|
||||
// Initialize with default states
|
||||
Object.entries(defaultAuthStates).forEach(([type, state]) => {
|
||||
authorizationStates.set(type as AuthorizationType, state);
|
||||
authorizationStates.set(
|
||||
type as AuthorizationType,
|
||||
state as AuthorizationContract,
|
||||
);
|
||||
});
|
||||
|
||||
const authorization = ref<AuthorizationContract>(
|
||||
requestStore.pendingRequestData?.authorization ?? {
|
||||
type: AuthorizationType.CurrentUser,
|
||||
},
|
||||
);
|
||||
const authorization = computed<AuthorizationContract>(() => {
|
||||
return (
|
||||
requestStore.pendingRequestData?.authorization ?? {
|
||||
type: AuthorizationType.CurrentUser,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const selectedType = ref<AuthorizationType>(
|
||||
authorization.value?.type ?? authorizationConfig.DEFAULT_TYPE,
|
||||
);
|
||||
const selectedType = computed<AuthorizationType>({
|
||||
get: () => authorization.value.type,
|
||||
set: newValue => {
|
||||
if (newValue !== authorization.value.type) {
|
||||
updateAuthorizationType(newValue);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/*
|
||||
* Actions.
|
||||
@@ -71,24 +81,42 @@ export function useRequestAuthorization() {
|
||||
*/
|
||||
const updateAuthorizationType = (newValue: AuthorizationType): void => {
|
||||
// Save current state before switching
|
||||
if (authorization.value) {
|
||||
authorizationStates.set(authorization.value.type, {
|
||||
...authorization.value,
|
||||
});
|
||||
}
|
||||
authorizationStates.set(authorization.value.type, {
|
||||
...authorization.value,
|
||||
});
|
||||
|
||||
// Switch to new type and restore its previous state
|
||||
const savedState = authorizationStates.get(newValue);
|
||||
const restoredAuth = savedState ? { ...savedState } : defaultAuthStates[newValue];
|
||||
const restoredAuth = (
|
||||
savedState ? { ...savedState } : defaultAuthStates[newValue]
|
||||
) as AuthorizationContract;
|
||||
|
||||
authorization.value = restoredAuth;
|
||||
selectedType.value = newValue;
|
||||
// Only update if actually different to prevent unnecessary store commits
|
||||
if (
|
||||
restoredAuth.type !== authorization.value.type ||
|
||||
JSON.stringify(restoredAuth.value) !==
|
||||
JSON.stringify(authorization.value.value)
|
||||
) {
|
||||
requestStore.updateAuthorization(restoredAuth);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCurrentAuthorizationValue = (
|
||||
newValue: string | number | { username: string; password: string },
|
||||
) => {
|
||||
authorization.value.value = newValue;
|
||||
const currentAuth = requestStore.pendingRequestData?.authorization ?? {
|
||||
type: AuthorizationType.CurrentUser,
|
||||
};
|
||||
|
||||
// Prevent redundant updates
|
||||
if (JSON.stringify(newValue) === JSON.stringify(currentAuth.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestStore.updateAuthorization({
|
||||
...currentAuth,
|
||||
value: newValue,
|
||||
} as AuthorizationContract);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -98,27 +126,16 @@ export function useRequestAuthorization() {
|
||||
* for execution and updates the local state cache.
|
||||
*/
|
||||
const saveAuthorizationToStore = (): void => {
|
||||
if (!authorization.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state cache
|
||||
authorizationStates.set(authorization.value.type, {
|
||||
...authorization.value,
|
||||
});
|
||||
|
||||
// Save to request store
|
||||
if (requestStore.pendingRequestData) {
|
||||
requestStore.updateAuthorization(authorization.value);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Watchers.
|
||||
*/
|
||||
|
||||
watch(selectedType, updateAuthorizationType);
|
||||
|
||||
watch(authorization, saveAuthorizationToStore, { deep: true });
|
||||
|
||||
return {
|
||||
|
||||
@@ -143,7 +143,7 @@ export function useRequestBody() {
|
||||
payloadType.value = newValue?.payloadType ?? RequestBodyTypeEnum.EMPTY;
|
||||
payload.value = generateCurrentPayload();
|
||||
},
|
||||
{ deep: true },
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch(payloadType, newValue => {
|
||||
|
||||
227
resources/js/composables/request/useSharedStateRestoration.ts
Normal file
227
resources/js/composables/request/useSharedStateRestoration.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Composable for handling shared link state restoration.
|
||||
*
|
||||
* Initializes and restores request/response state from shareable links.
|
||||
*/
|
||||
|
||||
import type { AuthorizationContract } from '@/interfaces/auth/authorization';
|
||||
import type { RequestLog } from '@/interfaces/history/logs';
|
||||
import type { Request, RequestBodyTypeEnum } from '@/interfaces/http';
|
||||
import type { Response } from '@/interfaces/http/response';
|
||||
import type { RouteDefinition } from '@/interfaces/routes/routes';
|
||||
import type { ShareableLinkPayload, SharedState } from '@/interfaces/share';
|
||||
import type { ParameterContract } from '@/interfaces/ui';
|
||||
import { ParameterType } from '@/interfaces/ui';
|
||||
import { useRequestsHistoryStore, useSharedStateStore } from '@/stores';
|
||||
import { useRequestBuilderStore } from '@/stores/request/useRequestBuilderStore';
|
||||
import { onMounted } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
/**
|
||||
* Maps header objects to ParameterContract format.
|
||||
*/
|
||||
function mapHeadersToParameterContract(
|
||||
headers: Array<{ key: string; value: string | number | boolean | null }>,
|
||||
): ParameterContract[] {
|
||||
return headers.map(header => ({
|
||||
key: header.key,
|
||||
value: String(header.value ?? ''),
|
||||
type: ParameterType.Text,
|
||||
enabled: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps query parameter objects to ParameterContract format.
|
||||
*/
|
||||
function mapQueryParametersToParameterContract(
|
||||
queryParameters: Array<{ key: string; value: string; type?: string }>,
|
||||
): ParameterContract[] {
|
||||
return queryParameters.map(param => ({
|
||||
key: param.key,
|
||||
value: param.value,
|
||||
type: param.type === 'file' ? ParameterType.File : ParameterType.Text,
|
||||
enabled: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a route definition from payload data.
|
||||
*/
|
||||
function buildRouteDefinition(payload: ShareableLinkPayload): RouteDefinition {
|
||||
return {
|
||||
endpoint: payload.endpoint,
|
||||
method: payload.method,
|
||||
schema: { shape: {}, extractionErrors: null },
|
||||
shortEndpoint: payload.endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Request object from the shared payload.
|
||||
*/
|
||||
function buildRequestFromPayload(payload: ShareableLinkPayload): Request {
|
||||
return {
|
||||
method: payload.method,
|
||||
endpoint: payload.endpoint,
|
||||
headers: mapHeadersToParameterContract(payload.headers),
|
||||
body: null,
|
||||
queryParameters: mapQueryParametersToParameterContract(payload.queryParameters),
|
||||
payloadType: payload.payloadType as RequestBodyTypeEnum,
|
||||
authorization: payload.authorization as AuthorizationContract,
|
||||
routeDefinition: buildRouteDefinition(payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Response object from the shared payload response data.
|
||||
*/
|
||||
function buildResponseFromPayload(
|
||||
responseData: NonNullable<ShareableLinkPayload['response']>,
|
||||
): Response {
|
||||
return {
|
||||
status: responseData.status,
|
||||
statusCode: responseData.statusCode,
|
||||
statusText: responseData.statusText,
|
||||
body: responseData.body,
|
||||
sizeInBytes: responseData.sizeInBytes,
|
||||
headers: responseData.headers.map(header => ({
|
||||
key: header.key,
|
||||
value: header.value,
|
||||
})),
|
||||
cookies: responseData.cookies,
|
||||
timestamp: responseData.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a synthesized RequestLog from payload data.
|
||||
*/
|
||||
function createSynthesizedLog(payload: ShareableLinkPayload): RequestLog {
|
||||
if (!payload.response) {
|
||||
throw new Error('Cannot create synthesized log without response data');
|
||||
}
|
||||
|
||||
return {
|
||||
durationInMs: payload.response.durationInMs,
|
||||
isProcessing: false,
|
||||
request: buildRequestFromPayload(payload),
|
||||
response: buildResponseFromPayload(payload.response),
|
||||
importedFromShare: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a notification about the restoration result.
|
||||
*/
|
||||
function showRestorationNotification(sharedState: SharedState): void {
|
||||
if (sharedState.error) {
|
||||
toast.error('Failed to Restore Shareable Link', {
|
||||
description: sharedState.error,
|
||||
duration: 8000,
|
||||
});
|
||||
} else if (!sharedState.routeExists) {
|
||||
toast.warning('Route Not Found', {
|
||||
description:
|
||||
'The shared route does not exist in any application. Request details are still displayed.',
|
||||
duration: 6000,
|
||||
});
|
||||
} else {
|
||||
toast.success('Shared Request Restored', {
|
||||
description:
|
||||
'Request and response have been imported from the shareable link.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the 'share' parameter from the URL.
|
||||
*/
|
||||
function clearShareUrlParameter(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('share');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the shared response to history.
|
||||
*/
|
||||
function addSharedResponseToHistory(
|
||||
payload: ShareableLinkPayload,
|
||||
historyStore: ReturnType<typeof useRequestsHistoryStore>,
|
||||
): void {
|
||||
if (payload.requestLog) {
|
||||
// Use the full request log if available
|
||||
const importedLog: RequestLog = {
|
||||
...payload.requestLog,
|
||||
importedFromShare: true,
|
||||
};
|
||||
historyStore.addLog(importedLog);
|
||||
} else if (payload.response) {
|
||||
// Create a synthesized log from the response
|
||||
const synthesizedLog = createSynthesizedLog(payload);
|
||||
historyStore.addLog(synthesizedLog);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes shared state restoration on component mount.
|
||||
*
|
||||
* This should be called in the main page component to restore
|
||||
* request/response state from shareable links.
|
||||
*/
|
||||
export function useSharedStateRestoration() {
|
||||
const sharedStateStore = useSharedStateStore();
|
||||
const requestBuilderStore = useRequestBuilderStore();
|
||||
const historyStore = useRequestsHistoryStore();
|
||||
|
||||
const restoreSharedState = () => {
|
||||
const sharedState = window.Nimbus?.sharedState as SharedState | null | undefined;
|
||||
|
||||
if (!sharedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedStateStore.sharedState = sharedState;
|
||||
sharedStateStore.isRestoredFromShare = true;
|
||||
|
||||
// If there's an error, show notification and skip restoration
|
||||
if (sharedState.error) {
|
||||
showRestorationNotification(sharedState);
|
||||
clearShareUrlParameter();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If there's no payload, nothing to restore
|
||||
if (!sharedState.payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestBuilderStore.restoreFromSharedPayload({
|
||||
method: sharedState.payload.method,
|
||||
endpoint: sharedState.payload.endpoint,
|
||||
headers: sharedState.payload.headers,
|
||||
queryParameters: sharedState.payload.queryParameters,
|
||||
body: sharedState.payload.body,
|
||||
payloadType: sharedState.payload.payloadType,
|
||||
authorization: sharedState.payload.authorization,
|
||||
durationInMs: sharedState.payload.response?.durationInMs,
|
||||
wasExecuted: !!sharedState.payload.response,
|
||||
});
|
||||
|
||||
addSharedResponseToHistory(sharedState.payload, historyStore);
|
||||
|
||||
showRestorationNotification(sharedState);
|
||||
|
||||
clearShareUrlParameter();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
restoreSharedState();
|
||||
});
|
||||
|
||||
return {
|
||||
restoreSharedState,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface RequestLog {
|
||||
request: Request;
|
||||
response?: Response;
|
||||
error?: ErrorPlainResponse;
|
||||
importedFromShare?: boolean;
|
||||
}
|
||||
|
||||
export type RequestLogRef = Ref<RequestLog | null>;
|
||||
|
||||
89
resources/js/interfaces/share/index.ts
Normal file
89
resources/js/interfaces/share/index.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Interfaces for shareable links feature
|
||||
*/
|
||||
|
||||
import { RequestLog } from '@/interfaces/history/logs';
|
||||
import { STATUS } from '@/interfaces/http/status';
|
||||
|
||||
/**
|
||||
* The payload structure stored in a shareable link.
|
||||
*
|
||||
* Contains the pending request state and optional response data
|
||||
* that will be restored when the link is accessed.
|
||||
*/
|
||||
export interface ShareableLinkPayload {
|
||||
/** The HTTP method for the request */
|
||||
method: string;
|
||||
|
||||
/** The API endpoint path */
|
||||
endpoint: string;
|
||||
|
||||
/** Request headers */
|
||||
headers: Array<{
|
||||
key: string;
|
||||
value: string | number | boolean | null;
|
||||
}>;
|
||||
|
||||
/** Query parameters */
|
||||
queryParameters: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
type?: 'text' | 'file';
|
||||
}>;
|
||||
|
||||
/** Request body organized by method and payload type */
|
||||
body: Record<
|
||||
string,
|
||||
Record<string, FormData | string | null | undefined> | undefined
|
||||
>;
|
||||
|
||||
/** The currently selected payload type */
|
||||
payloadType: string;
|
||||
|
||||
/** Authorization configuration */
|
||||
authorization: {
|
||||
type: string;
|
||||
value?: string | number | { username: string; password: string };
|
||||
};
|
||||
|
||||
/** The response data (if available) */
|
||||
response?: {
|
||||
status: STATUS;
|
||||
statusCode: number;
|
||||
statusText: string;
|
||||
body: string;
|
||||
sizeInBytes: number;
|
||||
headers: Array<{ key: string; value: string | number | boolean | null }>;
|
||||
cookies: Array<{
|
||||
key: string;
|
||||
value: {
|
||||
raw: string;
|
||||
decrypted: string | null;
|
||||
};
|
||||
}>;
|
||||
timestamp: number;
|
||||
durationInMs: number;
|
||||
};
|
||||
|
||||
/** Request log for history restoration */
|
||||
requestLog?: RequestLog;
|
||||
|
||||
/** Application key this request was created in */
|
||||
applicationKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared state injected by the backend into window.Nimbus.
|
||||
*
|
||||
* Contains decoded payload and route existence metadata.
|
||||
*/
|
||||
export interface SharedState {
|
||||
/** The decoded shareable link payload */
|
||||
payload: ShareableLinkPayload;
|
||||
|
||||
/** Whether the route exists in any application */
|
||||
routeExists: boolean;
|
||||
|
||||
/** Error message if decoding failed */
|
||||
error?: string;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import RequestBuilder from '@/components/domain/Client/Request/RequestBuilder.vu
|
||||
import ResponseViewer from '@/components/domain/Client/Response/ResponseViewer.vue';
|
||||
import RouteExtractorExceptionRenderer from '@/components/domain/Errors/RouteExtractorExceptionRenderer.vue';
|
||||
import RouteExplorer from '@/components/domain/RoutesExplorer/RouteExplorer.vue';
|
||||
import { useSharedStateRestoration } from '@/composables/request/useSharedStateRestoration';
|
||||
import { useResponsiveResizable } from '@/composables/ui/useResponsiveResizable';
|
||||
import { RouteExtractorException } from '@/interfaces';
|
||||
import { useRoutesStore } from '@/stores';
|
||||
@@ -23,6 +24,12 @@ defineOptions({
|
||||
|
||||
const routesStore = useRoutesStore();
|
||||
|
||||
/*
|
||||
* Shared state restoration (from shareable links).
|
||||
*/
|
||||
|
||||
useSharedStateRestoration();
|
||||
|
||||
/*
|
||||
* Lifecycle.
|
||||
*/
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
export { useConfigStore } from './useConfigStore';
|
||||
export { useSettingsStore } from './useSettingsStore';
|
||||
export { useSharedStateStore } from './useSharedStateStore';
|
||||
|
||||
91
resources/js/stores/core/useSharedStateStore.ts
Normal file
91
resources/js/stores/core/useSharedStateStore.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Store for managing shared link state restoration.
|
||||
*
|
||||
* Handles the initialization and restoration of request/response
|
||||
* state from shareable links.
|
||||
*/
|
||||
|
||||
import { SharedState } from '@/interfaces/share';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export const useSharedStateStore = defineStore('sharedState', () => {
|
||||
/*
|
||||
* State.
|
||||
*/
|
||||
|
||||
const sharedState = ref<SharedState | null>(null);
|
||||
const isRestoredFromShare = ref(false);
|
||||
|
||||
/*
|
||||
* Computed.
|
||||
*/
|
||||
|
||||
const hasSharedState = computed(() => sharedState.value !== null);
|
||||
|
||||
const wasImportedFromShare = computed(() => isRestoredFromShare.value);
|
||||
|
||||
const routeExists = computed(() => sharedState.value?.routeExists ?? true);
|
||||
|
||||
const sharedPayload = computed(() => sharedState.value?.payload);
|
||||
|
||||
const sharedError = computed(() => sharedState.value?.error);
|
||||
|
||||
/*
|
||||
* Actions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initializes the shared state from window.Nimbus.sharedState.
|
||||
*
|
||||
* This is called on app initialization to check if the current
|
||||
* page load was from a shareable link.
|
||||
*/
|
||||
const initializeFromWindow = () => {
|
||||
const windowSharedState = window.Nimbus?.sharedState as SharedState | undefined;
|
||||
|
||||
if (windowSharedState) {
|
||||
sharedState.value = windowSharedState;
|
||||
isRestoredFromShare.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks the state as consumed/restored.
|
||||
*
|
||||
* Called after the request builder has been populated with shared state.
|
||||
*/
|
||||
const markAsConsumed = () => {
|
||||
// Keep the state for UI indicators but prevent re-restoration
|
||||
isRestoredFromShare.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears the shared state.
|
||||
*
|
||||
* Called when the user makes new changes that should clear
|
||||
* the "imported from share" indicator.
|
||||
*/
|
||||
const clearSharedState = () => {
|
||||
sharedState.value = null;
|
||||
isRestoredFromShare.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
sharedState,
|
||||
isRestoredFromShare,
|
||||
|
||||
// Computed
|
||||
hasSharedState,
|
||||
wasImportedFromShare,
|
||||
routeExists,
|
||||
sharedPayload,
|
||||
sharedError,
|
||||
|
||||
// Actions
|
||||
initializeFromWindow,
|
||||
markAsConsumed,
|
||||
clearSharedState,
|
||||
};
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Pinia stores for global state management
|
||||
*/
|
||||
|
||||
export { useConfigStore, useSettingsStore } from './core';
|
||||
export { useConfigStore, useSettingsStore, useSharedStateStore } from './core';
|
||||
export { useValueGeneratorStore } from './generators';
|
||||
export { useRequestStore, useRequestsHistoryStore } from './request';
|
||||
export { useRoutesStore } from './routes';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AuthorizationContract } from '@/interfaces/auth/authorization';
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { PendingRequest, Request, RequestBodyTypeEnum } from '@/interfaces/http';
|
||||
import { RouteDefinition } from '@/interfaces/routes/routes';
|
||||
import { ParameterContract } from '@/interfaces/ui';
|
||||
import { ParameterContract, ParameterType } from '@/interfaces/ui';
|
||||
import { useConfigStore, useSettingsStore } from '@/stores';
|
||||
import { buildRequestUrl, getDefaultPayloadTypeForRoute } from '@/utils/request';
|
||||
import { defineStore } from 'pinia';
|
||||
@@ -315,6 +315,76 @@ export const useRequestBuilderStore = defineStore(
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores request state from a shareable link payload.
|
||||
*
|
||||
* This bypasses normal route initialization to directly restore
|
||||
* all request data from the shared payload.
|
||||
*/
|
||||
const restoreFromSharedPayload = (payload: {
|
||||
method: string;
|
||||
endpoint: string;
|
||||
headers: Array<{
|
||||
key: string;
|
||||
value: string | number | boolean | null;
|
||||
}>;
|
||||
queryParameters: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
type?: 'text' | 'file';
|
||||
}>;
|
||||
body: PendingRequest['body'];
|
||||
payloadType: string;
|
||||
authorization: {
|
||||
type: string;
|
||||
value?: string | number | { username: string; password: string };
|
||||
};
|
||||
durationInMs?: number;
|
||||
wasExecuted?: boolean;
|
||||
}) => {
|
||||
const wasExecuted = payload.wasExecuted ?? payload.durationInMs !== undefined;
|
||||
|
||||
pendingRequestData.value = {
|
||||
method: payload.method.toUpperCase(),
|
||||
endpoint: payload.endpoint,
|
||||
headers: payload.headers.map(header => ({
|
||||
key: header.key,
|
||||
value: String(header.value ?? ''),
|
||||
type: ParameterType.Text,
|
||||
enabled: true,
|
||||
})),
|
||||
body: payload.body,
|
||||
payloadType: payload.payloadType as RequestBodyTypeEnum,
|
||||
schema: {
|
||||
shape: {},
|
||||
extractionErrors: null,
|
||||
},
|
||||
queryParameters: payload.queryParameters.map(param => ({
|
||||
key: param.key,
|
||||
value: param.value,
|
||||
type: param.type === 'file' ? ParameterType.File : ParameterType.Text,
|
||||
enabled: true,
|
||||
})),
|
||||
authorization: {
|
||||
type: payload.authorization.type as AuthorizationType,
|
||||
value: payload.authorization.value,
|
||||
} as AuthorizationContract,
|
||||
supportedRoutes: [],
|
||||
routeDefinition: {
|
||||
endpoint: payload.endpoint,
|
||||
method: payload.method.toUpperCase(),
|
||||
schema: {
|
||||
shape: {},
|
||||
extractionErrors: null,
|
||||
},
|
||||
shortEndpoint: payload.endpoint,
|
||||
},
|
||||
isProcessing: false,
|
||||
wasExecuted,
|
||||
durationInMs: payload.durationInMs ?? 0,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
pendingRequestData,
|
||||
@@ -333,6 +403,7 @@ export const useRequestBuilderStore = defineStore(
|
||||
resetRequest,
|
||||
getRequestUrl,
|
||||
restoreFromHistory,
|
||||
restoreFromSharedPayload,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,86 +1,104 @@
|
||||
import { useRequestAuthorization } from '@/composables/request/useRequestAuthorization';
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useRequestStore } from '@/stores';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const pendingRequestData = reactive({
|
||||
authorization: { type: AuthorizationType.None, value: null },
|
||||
});
|
||||
|
||||
const updateAuthorization = vi.fn();
|
||||
|
||||
vi.mock('@/stores', async importOriginal => {
|
||||
const actual = await importOriginal<object>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRequestStore: () => ({
|
||||
pendingRequestData,
|
||||
updateAuthorization,
|
||||
}),
|
||||
};
|
||||
});
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
describe('useRequestAuthorization', () => {
|
||||
let requestStore: ReturnType<typeof useRequestStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
pendingRequestData.authorization = { type: AuthorizationType.None, value: null };
|
||||
updateAuthorization.mockClear();
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
stubActions: false,
|
||||
initialState: {
|
||||
_requestBuilder: {
|
||||
pendingRequestData: {
|
||||
authorization: { type: AuthorizationType.None, value: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
requestStore = useRequestStore();
|
||||
});
|
||||
|
||||
it('initializes with current request authorization', () => {
|
||||
setActivePinia(createPinia());
|
||||
|
||||
const { authorization } = useRequestAuthorization();
|
||||
|
||||
expect(authorization.value.type).toBe(AuthorizationType.None);
|
||||
});
|
||||
|
||||
it('switches between authorization types and restores cached state', () => {
|
||||
setActivePinia(createPinia());
|
||||
it('initializes with default authorization if not set in store', () => {
|
||||
// @ts-expect-error Attempt to assign to const or readonly variable
|
||||
requestStore.pendingRequestData = null;
|
||||
|
||||
const { authorization } = useRequestAuthorization();
|
||||
|
||||
expect(authorization.value.type).toBe(AuthorizationType.CurrentUser);
|
||||
});
|
||||
|
||||
it('switches between authorization types and restores cached state', async () => {
|
||||
const {
|
||||
authorization,
|
||||
updateAuthorizationType,
|
||||
updateCurrentAuthorizationValue,
|
||||
} = useRequestAuthorization();
|
||||
|
||||
// Switch to Bearer
|
||||
updateAuthorizationType(AuthorizationType.Bearer);
|
||||
await nextTick();
|
||||
|
||||
// Set value
|
||||
updateCurrentAuthorizationValue('token');
|
||||
await nextTick();
|
||||
|
||||
expect(authorization.value).toEqual({
|
||||
type: AuthorizationType.Bearer,
|
||||
value: 'token',
|
||||
});
|
||||
|
||||
// Switch to Basic
|
||||
updateAuthorizationType(AuthorizationType.Basic);
|
||||
expect(authorization.value.type).toBe(AuthorizationType.Basic);
|
||||
await nextTick();
|
||||
|
||||
expect(authorization.value.type).toBe(AuthorizationType.Basic);
|
||||
expect(authorization.value.value).toEqual({ username: '', password: '' });
|
||||
|
||||
// Switch back to Bearer - should restore 'token'
|
||||
updateAuthorizationType(AuthorizationType.Bearer);
|
||||
await nextTick();
|
||||
|
||||
expect(authorization.value).toEqual({
|
||||
type: AuthorizationType.Bearer,
|
||||
value: 'token',
|
||||
});
|
||||
});
|
||||
|
||||
it('persists authorization back to the request store', () => {
|
||||
setActivePinia(createPinia());
|
||||
it('persists authorization back to the request store via actions', async () => {
|
||||
const { updateAuthorizationType, updateCurrentAuthorizationValue } =
|
||||
useRequestAuthorization();
|
||||
|
||||
updateAuthorization.mockClear();
|
||||
|
||||
const {
|
||||
saveAuthorizationToStore,
|
||||
updateAuthorizationType,
|
||||
updateCurrentAuthorizationValue,
|
||||
} = useRequestAuthorization();
|
||||
const spy = vi.spyOn(requestStore, 'updateAuthorization');
|
||||
|
||||
// Switch to Bearer
|
||||
updateAuthorizationType(AuthorizationType.Bearer);
|
||||
await nextTick();
|
||||
|
||||
// Set value
|
||||
updateCurrentAuthorizationValue('token');
|
||||
await nextTick();
|
||||
|
||||
saveAuthorizationToStore();
|
||||
|
||||
expect(updateAuthorization).toHaveBeenCalledWith({
|
||||
type: AuthorizationType.Bearer,
|
||||
value: 'token',
|
||||
});
|
||||
// Check if it was called with the final expected state
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: AuthorizationType.Bearer,
|
||||
value: 'token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,4 +124,19 @@ describe('useRequestBody', () => {
|
||||
expect(payloadMocks.serializeSchemaPayload).toHaveBeenCalled();
|
||||
expect(composable.payload.value).toBe('{"serialized":true}');
|
||||
});
|
||||
|
||||
it('initializes immediately from store during setup', () => {
|
||||
requestStore.pendingRequestData = createPendingRequest();
|
||||
requestStore.pendingRequestData.payloadType = RequestBodyTypeEnum.JSON;
|
||||
requestStore.pendingRequestData.body = {
|
||||
POST: {
|
||||
[RequestBodyTypeEnum.JSON]: '{"immediate":true}',
|
||||
},
|
||||
};
|
||||
|
||||
const composable = runComposable();
|
||||
|
||||
expect(composable.payloadType.value).toBe(RequestBodyTypeEnum.JSON);
|
||||
expect(composable.payload.value).toBe('{"immediate":true}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('useConfigStore', () => {
|
||||
routeExtractorException: null,
|
||||
applications: JSON.stringify({ main: 'Main API' }),
|
||||
activeApplication: 'main',
|
||||
sharedState: null,
|
||||
};
|
||||
|
||||
const store = useConfigStore();
|
||||
|
||||
145
resources/js/tests/utils/shareableLinks.test.ts
Normal file
145
resources/js/tests/utils/shareableLinks.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { AuthorizationType } from '@/interfaces/generated';
|
||||
import type { PendingRequest, Response } from '@/interfaces/http';
|
||||
import { RequestBodyTypeEnum, STATUS } from '@/interfaces/http';
|
||||
import { ParameterType } from '@/interfaces/ui';
|
||||
import { buildShareableUrl, encodeShareablePayload } from '@/utils/shareableLinks';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('shareableLinks', () => {
|
||||
describe('encodeShareablePayload', () => {
|
||||
it('encodes a pending request into a URL-safe string', () => {
|
||||
// Arrange
|
||||
|
||||
const pendingRequest: PendingRequest = {
|
||||
method: 'POST',
|
||||
endpoint: '/api/users',
|
||||
headers: [
|
||||
{
|
||||
key: 'Content-Type',
|
||||
value: 'application/json',
|
||||
type: ParameterType.Text,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
queryParameters: [
|
||||
{
|
||||
key: 'page',
|
||||
value: '1',
|
||||
type: ParameterType.Text,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
body: {},
|
||||
payloadType: RequestBodyTypeEnum.JSON,
|
||||
schema: { shape: {}, extractionErrors: null },
|
||||
authorization: { type: AuthorizationType.None },
|
||||
supportedRoutes: [],
|
||||
routeDefinition: {
|
||||
endpoint: '/api/users',
|
||||
method: 'POST',
|
||||
schema: { shape: {}, extractionErrors: null },
|
||||
shortEndpoint: '/users',
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
const encoded = encodeShareablePayload(pendingRequest);
|
||||
|
||||
// Assert
|
||||
|
||||
expect(encoded).toBeDefined();
|
||||
expect(typeof encoded).toBe('string');
|
||||
expect(encoded.length).toBeGreaterThan(0);
|
||||
// Should be URL-safe (no +, /, or = characters)
|
||||
expect(encoded).not.toMatch(/[+/=]/);
|
||||
});
|
||||
|
||||
it('encodes request with response data', () => {
|
||||
// Arrange
|
||||
|
||||
const pendingRequest: PendingRequest = {
|
||||
method: 'GET',
|
||||
endpoint: '/api/health',
|
||||
headers: [],
|
||||
queryParameters: [],
|
||||
body: {},
|
||||
payloadType: RequestBodyTypeEnum.EMPTY,
|
||||
schema: { shape: {}, extractionErrors: null },
|
||||
authorization: { type: AuthorizationType.None },
|
||||
supportedRoutes: [],
|
||||
routeDefinition: {
|
||||
endpoint: '/api/health',
|
||||
method: 'GET',
|
||||
schema: { shape: {}, extractionErrors: null },
|
||||
shortEndpoint: '/health',
|
||||
},
|
||||
};
|
||||
|
||||
const response: Response = {
|
||||
status: STATUS.SUCCESS,
|
||||
statusCode: 200,
|
||||
statusText: 'OK',
|
||||
body: '{"status": "healthy"}',
|
||||
sizeInBytes: 21,
|
||||
headers: [{ key: 'Content-Type', value: 'application/json' }],
|
||||
cookies: [],
|
||||
timestamp: 1234567890,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
const encoded = encodeShareablePayload(pendingRequest, response);
|
||||
|
||||
// Assert
|
||||
|
||||
expect(encoded).toBeDefined();
|
||||
expect(encoded.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildShareableUrl', () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.location for tests
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
location: {
|
||||
origin: 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a complete URL with share parameter', () => {
|
||||
// Arrange
|
||||
|
||||
const basePath = '/nimbus';
|
||||
const encodedPayload = 'encodedPayloadString';
|
||||
|
||||
// Act
|
||||
|
||||
const url = buildShareableUrl(basePath, encodedPayload);
|
||||
|
||||
// Assert
|
||||
|
||||
expect(url).toBe('http://localhost:3000/nimbus?share=encodedPayloadString');
|
||||
});
|
||||
|
||||
it('handles base path without leading slash', () => {
|
||||
// Arrange
|
||||
|
||||
const basePath = 'nimbus';
|
||||
const encodedPayload = 'test123';
|
||||
|
||||
// Act
|
||||
|
||||
const url = buildShareableUrl(basePath, encodedPayload);
|
||||
|
||||
// Assert
|
||||
|
||||
expect(url).toBe('http://localhost:3000/nimbus?share=test123');
|
||||
});
|
||||
});
|
||||
});
|
||||
96
resources/js/utils/shareableLinks.ts
Normal file
96
resources/js/utils/shareableLinks.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Utilities for encoding and decoding shareable link payloads.
|
||||
*
|
||||
* Uses pako (gzip/deflate) for compression to keep URLs within browser limits.
|
||||
*/
|
||||
|
||||
import type { RequestLog } from '@/interfaces/history/logs';
|
||||
import type { PendingRequest, Response } from '@/interfaces/http';
|
||||
import type { ShareableLinkPayload } from '@/interfaces/share';
|
||||
import pako from 'pako';
|
||||
|
||||
/**
|
||||
* Encodes a pending request and optional response into a URL-safe shareable string.
|
||||
*
|
||||
* The process:
|
||||
* 1. Create a minimal payload with essential request/response data
|
||||
* 2. Serialize to JSON
|
||||
* 3. Compress using pako (deflate)
|
||||
* 4. Base64 encode with URL-safe characters
|
||||
*/
|
||||
export function encodeShareablePayload(
|
||||
pendingRequest: PendingRequest,
|
||||
response?: Response,
|
||||
requestLog?: RequestLog,
|
||||
applicationKey?: string,
|
||||
): string {
|
||||
const payload: ShareableLinkPayload = {
|
||||
method: pendingRequest.method,
|
||||
endpoint: pendingRequest.endpoint,
|
||||
headers: pendingRequest.headers.map(header => ({
|
||||
key: header.key,
|
||||
value: header.value,
|
||||
})),
|
||||
queryParameters: pendingRequest.queryParameters.map(param => ({
|
||||
key: param.key,
|
||||
value: param.value,
|
||||
type: param.type,
|
||||
})),
|
||||
body: pendingRequest.body,
|
||||
payloadType: pendingRequest.payloadType,
|
||||
authorization: {
|
||||
type: pendingRequest.authorization.type,
|
||||
value: pendingRequest.authorization.value,
|
||||
},
|
||||
applicationKey,
|
||||
};
|
||||
|
||||
if (response) {
|
||||
payload.response = {
|
||||
status: response.status,
|
||||
statusCode: response.statusCode,
|
||||
statusText: response.statusText,
|
||||
body: response.body,
|
||||
sizeInBytes: response.sizeInBytes,
|
||||
headers: response.headers.map(header => ({
|
||||
key: header.key,
|
||||
value: header.value,
|
||||
})),
|
||||
cookies: response.cookies.map(cookie => ({
|
||||
key: cookie.key,
|
||||
value: cookie.value,
|
||||
})),
|
||||
timestamp: response.timestamp,
|
||||
durationInMs: requestLog?.durationInMs ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestLog) {
|
||||
payload.requestLog = requestLog;
|
||||
}
|
||||
|
||||
// Serialize to JSON
|
||||
const jsonString = JSON.stringify(payload);
|
||||
|
||||
// Compress using pako (deflate)
|
||||
const compressed = pako.deflate(jsonString);
|
||||
|
||||
// Convert to base64 with URL-safe characters
|
||||
const base64 = btoa(String.fromCharCode.apply(null, Array.from(compressed)));
|
||||
const urlSafeBase64 = base64
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return urlSafeBase64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete shareable URL for the current request.
|
||||
*/
|
||||
export function buildShareableUrl(basePath: string, encodedPayload: string): string {
|
||||
const baseUrl = window.location.origin;
|
||||
const cleanBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`;
|
||||
|
||||
return `${baseUrl}${cleanBasePath}?share=${encodedPayload}`;
|
||||
}
|
||||
3
resources/types/global.d.ts
vendored
3
resources/types/global.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { SharedState } from '../js/interfaces/share';
|
||||
|
||||
interface NimbusConfig {
|
||||
basePath: string;
|
||||
@@ -10,6 +10,7 @@ interface NimbusConfig {
|
||||
currentUser: string | null;
|
||||
applications: string | null;
|
||||
activeApplication: string | null;
|
||||
sharedState: SharedState | null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
'currentUser' => isset($currentUser) ? json_encode($currentUser) : null,
|
||||
'applications' => $activeApplicationResolver->getAvailableApplications(),
|
||||
'activeApplication' => $activeApplicationResolver->getActiveApplicationKey(),
|
||||
'sharedState' => isset($sharedState) ? $sharedState : null,
|
||||
]);
|
||||
|
||||
$configTag = new \Illuminate\Support\HtmlString(<<<HTML
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\Str;
|
||||
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
|
||||
use Sunchayn\Nimbus\Modules\Export\Services\ShareableLinkProcessorService;
|
||||
use Sunchayn\Nimbus\Modules\Routes\Actions;
|
||||
use Sunchayn\Nimbus\Modules\Routes\Exceptions\RouteExtractionException;
|
||||
|
||||
@@ -22,59 +23,123 @@ class NimbusIndexController
|
||||
Actions\BuildCurrentUserAction $buildCurrentUserAction,
|
||||
Actions\DisableThirdPartyUiAction $disableThirdPartyUiAction,
|
||||
ActiveApplicationResolver $activeApplicationResolver,
|
||||
ShareableLinkProcessorService $shareableLinkProcessorService,
|
||||
): Renderable|RedirectResponse {
|
||||
$incomingShareableLinkPayload = $this->processShareableLink($shareableLinkProcessorService, $activeApplicationResolver);
|
||||
|
||||
$this->handleApplicationSwitch();
|
||||
|
||||
$this->handleIgnoreRouteError($ignoreRouteErrorAction);
|
||||
|
||||
$this->configureVite();
|
||||
|
||||
$disableThirdPartyUiAction->execute();
|
||||
|
||||
if (request()->has('application')) {
|
||||
return redirect()
|
||||
->to(request()->fullUrlWithQuery(['application' => null]))
|
||||
->withCookie(cookie()->forever(ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME, request()->get('application')));
|
||||
}
|
||||
|
||||
Vite::useBuildDirectory('/vendor/nimbus');
|
||||
Vite::useHotFile(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'));
|
||||
|
||||
if (request()->has('ignore')) {
|
||||
$ignoreRouteErrorAction->execute(
|
||||
ignoreData: request()->get('ignore'),
|
||||
);
|
||||
|
||||
return redirect()->to(request()->url());
|
||||
}
|
||||
$baseViewData = [
|
||||
'activeApplicationResolver' => $activeApplicationResolver,
|
||||
'sharedState' => $incomingShareableLinkPayload,
|
||||
];
|
||||
|
||||
try {
|
||||
$routes = $extractRoutesAction
|
||||
->execute(
|
||||
routes: RouteFacade::getRoutes()->getRoutes(),
|
||||
);
|
||||
} catch (RouteExtractionException $routeExtractionException) {
|
||||
return view(self::VIEW_NAME, [ // @phpstan-ignore-line it cannot find the view.
|
||||
'routeExtractorException' => $this->renderExtractorException($routeExtractionException),
|
||||
'activeApplicationResolver' => $activeApplicationResolver,
|
||||
]);
|
||||
}
|
||||
$routes = $extractRoutesAction->execute(
|
||||
routes: RouteFacade::getRoutes()->getRoutes(),
|
||||
);
|
||||
|
||||
return view(self::VIEW_NAME, [ // @phpstan-ignore-line it cannot find the view.
|
||||
'routes' => $routes->toFrontendArray(),
|
||||
'headers' => $buildGlobalHeadersAction->execute(),
|
||||
'currentUser' => $buildCurrentUserAction->execute(),
|
||||
'activeApplicationResolver' => $activeApplicationResolver,
|
||||
]);
|
||||
return view(self::VIEW_NAME, array_merge($baseViewData, [ // @phpstan-ignore-line it cannot find the view.
|
||||
'routes' => $routes->toFrontendArray(),
|
||||
'headers' => $buildGlobalHeadersAction->execute(),
|
||||
'currentUser' => $buildCurrentUserAction->execute(),
|
||||
]));
|
||||
} catch (RouteExtractionException $routeExtractionException) {
|
||||
return view(self::VIEW_NAME, array_merge($baseViewData, [ // @phpstan-ignore-line it cannot find the view.
|
||||
'routeExtractorException' => $this->formatExtractionException($routeExtractionException),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, array<int|string>|string|null>|string|null>
|
||||
* Process shareable link if present in request.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function renderExtractorException(RouteExtractionException $routeExtractionException): array
|
||||
private function processShareableLink(
|
||||
ShareableLinkProcessorService $shareableLinkProcessorService,
|
||||
ActiveApplicationResolver $activeApplicationResolver
|
||||
): ?array {
|
||||
$shareParam = request()->get('share');
|
||||
|
||||
if (! is_string($shareParam)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$shareableLinkProcessorService->process($shareParam);
|
||||
|
||||
$targetApp = $shareableLinkProcessorService->getTargetApplication();
|
||||
|
||||
if ($targetApp && $targetApp !== $activeApplicationResolver->getActiveApplicationKey()) {
|
||||
$this->redirectWithApplicationCookie($targetApp);
|
||||
}
|
||||
|
||||
return $shareableLinkProcessorService->toFrontendState();
|
||||
}
|
||||
|
||||
private function handleApplicationSwitch(): void
|
||||
{
|
||||
$application = request()->query('application');
|
||||
|
||||
if ($application === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirectWithApplicationCookie($application);
|
||||
}
|
||||
|
||||
private function redirectWithApplicationCookie(string $application): never
|
||||
{
|
||||
abort(
|
||||
redirect()
|
||||
->to(request()->fullUrlWithQuery(['application' => null]))
|
||||
->withCookie(cookie()->forever(
|
||||
ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME,
|
||||
$application
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private function configureVite(): void
|
||||
{
|
||||
Vite::useBuildDirectory('/vendor/nimbus');
|
||||
Vite::useHotFile(base_path('/vendor/sunchayn/nimbus/resources/dist/hot'));
|
||||
}
|
||||
|
||||
private function handleIgnoreRouteError(Actions\IgnoreRouteErrorAction $ignoreRouteErrorAction): void
|
||||
{
|
||||
$ignoreData = request()->get('ignore');
|
||||
|
||||
if ($ignoreData === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ignoreRouteErrorAction->execute(ignoreData: $ignoreData);
|
||||
|
||||
abort(redirect()->to(request()->url()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatExtractionException(RouteExtractionException $routeExtractionException): array
|
||||
{
|
||||
$previous = $routeExtractionException->getPrevious();
|
||||
|
||||
return [
|
||||
'exception' => [
|
||||
'message' => $routeExtractionException->getMessage(),
|
||||
'previous' => $routeExtractionException->getPrevious() instanceof \Throwable ? [
|
||||
'message' => $routeExtractionException->getPrevious()->getMessage(),
|
||||
'file' => $routeExtractionException->getPrevious()->getFile(),
|
||||
'line' => $routeExtractionException->getPrevious()->getLine(),
|
||||
'trace' => Str::replace("\n", '<br/>', $routeExtractionException->getPrevious()->getTraceAsString()),
|
||||
'previous' => $previous instanceof \Throwable ? [
|
||||
'message' => $previous->getMessage(),
|
||||
'file' => $previous->getFile(),
|
||||
'line' => $previous->getLine(),
|
||||
'trace' => Str::replace("\n", '<br/>', $previous->getTraceAsString()),
|
||||
] : null,
|
||||
],
|
||||
'routeContext' => $routeExtractionException->getRouteContext(),
|
||||
|
||||
232
src/Modules/Export/Services/ShareableLinkProcessorService.php
Normal file
232
src/Modules/Export/Services/ShareableLinkProcessorService.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace Sunchayn\Nimbus\Modules\Export\Services;
|
||||
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Routing\RouteCollection;
|
||||
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||
|
||||
/**
|
||||
* Decodes and processes shareable link payloads.
|
||||
*
|
||||
* Decompresses the URL-safe base64 payload and discovers which
|
||||
* application the shared route belongs to.
|
||||
*/
|
||||
class ShareableLinkProcessorService
|
||||
{
|
||||
/** @var array<string, mixed>|null */
|
||||
protected ?array $decodedPayload = null;
|
||||
|
||||
protected bool $routeExists = true;
|
||||
|
||||
protected ?string $targetApplication = null;
|
||||
|
||||
protected ?string $error = null;
|
||||
|
||||
public function __construct(
|
||||
protected Repository $config,
|
||||
) {}
|
||||
|
||||
public function process(string $shareParam): self
|
||||
{
|
||||
try {
|
||||
$this->decodedPayload = $this->decodePayload($shareParam);
|
||||
$this->discoverRoute();
|
||||
} catch (\Exception $exception) {
|
||||
$this->error = $exception->getMessage();
|
||||
$this->routeExists = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasPayload(): bool
|
||||
{
|
||||
return $this->decodedPayload !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function getDecodedPayload(): ?array
|
||||
{
|
||||
return $this->decodedPayload;
|
||||
}
|
||||
|
||||
public function routeExists(): bool
|
||||
{
|
||||
return $this->routeExists;
|
||||
}
|
||||
|
||||
public function getTargetApplication(): ?string
|
||||
{
|
||||
return $this->targetApplication;
|
||||
}
|
||||
|
||||
public function getError(): ?string
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payload: array<string, mixed>|null, routeExists: bool, error: string|null}
|
||||
*/
|
||||
public function toFrontendState(): array
|
||||
{
|
||||
return [
|
||||
'payload' => $this->decodedPayload,
|
||||
'routeExists' => $this->routeExists,
|
||||
'error' => $this->error,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
protected function decodePayload(string $encoded): array
|
||||
{
|
||||
$base64 = $this->restoreBase64FromUrlSafe($encoded);
|
||||
$compressed = $this->decodeBase64($base64);
|
||||
$json = $this->decompress($compressed);
|
||||
|
||||
return $this->parseJson($json);
|
||||
}
|
||||
|
||||
private function restoreBase64FromUrlSafe(string $encoded): string
|
||||
{
|
||||
$base64 = strtr($encoded, '-_', '+/');
|
||||
$padding = strlen($base64) % 4;
|
||||
|
||||
return $padding > 0
|
||||
? $base64.str_repeat('=', 4 - $padding)
|
||||
: $base64;
|
||||
}
|
||||
|
||||
private function decodeBase64(string $base64): string
|
||||
{
|
||||
$decoded = base64_decode($base64, true);
|
||||
|
||||
if ($decoded === false) {
|
||||
throw new \RuntimeException('Failed to decode base64 payload');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function decompress(string $compressed): string
|
||||
{
|
||||
$json = @gzuncompress($compressed);
|
||||
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Failed to decompress payload');
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function parseJson(string $json): array
|
||||
{
|
||||
/** @var array<string, mixed>|null $decoded */
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new \RuntimeException('Failed to parse JSON payload: '.json_last_error_msg());
|
||||
}
|
||||
|
||||
return $decoded ?? [];
|
||||
}
|
||||
|
||||
protected function discoverRoute(): void
|
||||
{
|
||||
$method = $this->decodedPayload['method'] ?? 'GET';
|
||||
$endpoint = $this->decodedPayload['endpoint'] ?? '/';
|
||||
|
||||
$this->searchRouteInOtherApplications($method, $endpoint);
|
||||
|
||||
if ($this->targetApplication !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->routeExistsInCurrentApplication($method, $endpoint)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->routeExists = false;
|
||||
}
|
||||
|
||||
private function routeExistsInCurrentApplication(string $method, string $endpoint): bool
|
||||
{
|
||||
return $this->findMatchingRoute($method, $endpoint) instanceof \Illuminate\Routing\Route;
|
||||
}
|
||||
|
||||
private function searchRouteInOtherApplications(string $method, string $endpoint): void
|
||||
{
|
||||
/** @var array<string, array{routes?: array{prefix?: string}}> $applications */
|
||||
$applications = $this->config->get('nimbus.applications', []);
|
||||
|
||||
foreach ($applications as $applicationKey => $appConfig) {
|
||||
$prefix = $appConfig['routes']['prefix'] ?? 'api';
|
||||
|
||||
if ($this->findMatchingRoute($method, $endpoint, $prefix) instanceof \Illuminate\Routing\Route) {
|
||||
$this->targetApplication = $applicationKey;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function findMatchingRoute(string $method, string $endpoint, ?string $prefix = null): ?Route
|
||||
{
|
||||
/** @var RouteCollection $routeCollection */
|
||||
$routeCollection = RouteFacade::getRoutes();
|
||||
|
||||
foreach ($routeCollection as $route) {
|
||||
if ($this->routeMatches($route, $method, $endpoint, $prefix)) {
|
||||
return $route;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function routeMatches(Route $route, string $method, string $endpoint, ?string $prefix = null): bool
|
||||
{
|
||||
if (! $this->methodMatches($route, $method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalizedEndpoint = $this->normalizePath($endpoint);
|
||||
|
||||
if ($prefix !== null && ! str_starts_with($normalizedEndpoint, $this->normalizePath($prefix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->pathMatchesRoutePattern($normalizedEndpoint, $route->uri());
|
||||
}
|
||||
|
||||
private function methodMatches(Route $route, string $method): bool
|
||||
{
|
||||
$routeMethods = array_map('strtoupper', $route->methods());
|
||||
|
||||
return in_array(strtoupper($method), $routeMethods, true);
|
||||
}
|
||||
|
||||
private function normalizePath(string $path): string
|
||||
{
|
||||
return '/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function pathMatchesRoutePattern(string $path, string $routeUri): bool
|
||||
{
|
||||
$normalizedRouteUri = $this->normalizePath($routeUri);
|
||||
$pattern = preg_replace('/\{[^}]+\}/', '[^/]+', $normalizedRouteUri);
|
||||
|
||||
return (bool) preg_match('#^'.$pattern.'$#', $path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
namespace Sunchayn\Nimbus\Tests\App\Modules\Export\Services;
|
||||
|
||||
use Generator;
|
||||
use Illuminate\Contracts\Config\Repository;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Sunchayn\Nimbus\Modules\Export\Services\ShareableLinkProcessorService;
|
||||
use Sunchayn\Nimbus\Tests\TestCase;
|
||||
|
||||
#[CoversClass(ShareableLinkProcessorService::class)]
|
||||
class ShareableLinkProcessorServiceUnitTest extends TestCase
|
||||
{
|
||||
private Repository|MockInterface $configMock;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->configMock = Mockery::mock(Repository::class);
|
||||
}
|
||||
|
||||
protected function defineRoutes($router): void
|
||||
{
|
||||
$router->get('/api/test', fn () => 'test');
|
||||
$router->post('/admin/users', fn () => 'users');
|
||||
$router->get('/admin/test', fn () => 'test');
|
||||
}
|
||||
|
||||
public function test_it_decodes_valid_shareable_link_payload(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->createMinimalPayload(method: 'GET', endpoint: '/api/users');
|
||||
$processor = $this->createProcessor($payload);
|
||||
|
||||
// Act
|
||||
|
||||
$decodedPayload = $processor->getDecodedPayload();
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertTrue($processor->hasPayload());
|
||||
$this->assertNotNull($decodedPayload);
|
||||
$this->assertSame('GET', $decodedPayload['method']);
|
||||
$this->assertSame('/api/users', $decodedPayload['endpoint']);
|
||||
}
|
||||
|
||||
public function test_it_handles_invalid_base64_gracefully(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$this->expectNoApplicationLookup();
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process('!!!invalid-base64!!!');
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertFalse($processor->routeExists());
|
||||
$this->assertNotNull($processor->getError());
|
||||
}
|
||||
|
||||
public function test_it_preserves_headers_and_query_parameters(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
...$this->basePayload(),
|
||||
'method' => 'POST',
|
||||
'endpoint' => '/api/orders',
|
||||
'headers' => [
|
||||
['key' => 'Authorization', 'value' => 'Bearer token123'],
|
||||
['key' => 'Content-Type', 'value' => 'application/json'],
|
||||
],
|
||||
'queryParameters' => [
|
||||
['key' => 'page', 'value' => '1'],
|
||||
['key' => 'limit', 'value' => '25'],
|
||||
],
|
||||
'payloadType' => 'json',
|
||||
'authorization' => ['type' => 'bearer', 'value' => 'token123'],
|
||||
]);
|
||||
|
||||
$processor = $this->createProcessor($payload);
|
||||
|
||||
// Act
|
||||
|
||||
$decodedPayload = $processor->getDecodedPayload();
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertNotNull($decodedPayload);
|
||||
$this->assertCount(2, $decodedPayload['headers']);
|
||||
$this->assertSame('Authorization', $decodedPayload['headers'][0]['key']);
|
||||
$this->assertCount(2, $decodedPayload['queryParameters']);
|
||||
$this->assertSame('page', $decodedPayload['queryParameters'][0]['key']);
|
||||
}
|
||||
|
||||
public function test_it_sets_route_exists_to_false_when_route_not_found(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->createMinimalPayload(method: 'DELETE', endpoint: '/api/non-existent-route');
|
||||
$processor = $this->createProcessor($payload);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertFalse($processor->routeExists());
|
||||
}
|
||||
|
||||
public function test_to_frontend_state_returns_complete_structure(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->createMinimalPayload();
|
||||
$processor = $this->createProcessor($payload);
|
||||
|
||||
// Act
|
||||
|
||||
$frontendState = $processor->toFrontendState();
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertArrayHasKey('payload', $frontendState);
|
||||
$this->assertArrayHasKey('routeExists', $frontendState);
|
||||
$this->assertArrayHasKey('error', $frontendState);
|
||||
}
|
||||
|
||||
#[DataProvider('providePayloadCompressionScenarios')]
|
||||
public function test_it_handles_different_payload_sizes(array $payloadData): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->encodePayload($payloadData);
|
||||
$processor = $this->createProcessor($payload);
|
||||
|
||||
// Act
|
||||
|
||||
$decodedPayload = $processor->getDecodedPayload();
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertTrue($processor->hasPayload());
|
||||
$this->assertSame($payloadData['method'], $decodedPayload['method']);
|
||||
}
|
||||
|
||||
public static function providePayloadCompressionScenarios(): Generator
|
||||
{
|
||||
yield 'minimal payload' => [
|
||||
'payloadData' => [
|
||||
'method' => 'GET',
|
||||
'endpoint' => '/api',
|
||||
'headers' => [],
|
||||
'queryParameters' => [],
|
||||
'body' => [],
|
||||
'payloadType' => 'empty',
|
||||
'authorization' => ['type' => 'none'],
|
||||
],
|
||||
];
|
||||
|
||||
yield 'payload with response' => [
|
||||
'payloadData' => [
|
||||
'method' => 'POST',
|
||||
'endpoint' => '/api/users',
|
||||
'headers' => [['key' => 'X-Test', 'value' => 'test']],
|
||||
'queryParameters' => [],
|
||||
'body' => [],
|
||||
'payloadType' => 'json',
|
||||
'authorization' => ['type' => 'none'],
|
||||
'response' => [
|
||||
'status' => 200,
|
||||
'statusCode' => 200,
|
||||
'statusText' => 'OK',
|
||||
'body' => '{"id": 1, "name": "Test User"}',
|
||||
'sizeInBytes' => 30,
|
||||
'headers' => [],
|
||||
'cookies' => [],
|
||||
'timestamp' => 1234567890,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_it_handles_decompression_failure(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$this->expectNoApplicationLookup();
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Create invalid compressed data (valid base64 but invalid gzip)
|
||||
$invalidCompressed = base64_encode('not-gzip-data');
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process($invalidCompressed);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertFalse($processor->routeExists());
|
||||
$this->assertNotNull($processor->getError());
|
||||
$this->assertStringContainsString('Failed to decompress payload', $processor->getError());
|
||||
}
|
||||
|
||||
public function test_it_handles_json_parsing_failure(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$this->expectNoApplicationLookup();
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Create valid gzip but invalid JSON
|
||||
$invalidJson = gzcompress('not valid json {]');
|
||||
$encoded = base64_encode($invalidJson);
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process($encoded);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertFalse($processor->routeExists());
|
||||
$this->assertNotNull($processor->getError());
|
||||
$this->assertStringContainsString('JSON', $processor->getError());
|
||||
}
|
||||
|
||||
public function test_it_handles_url_safe_base64_with_padding(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->basePayload();
|
||||
$payload['method'] = 'GET';
|
||||
$payload['endpoint'] = '/api/test';
|
||||
|
||||
$json = json_encode($payload);
|
||||
$compressed = gzcompress($json);
|
||||
$base64 = base64_encode($compressed);
|
||||
|
||||
// Convert to URL-safe and remove padding
|
||||
$urlSafe = rtrim(strtr($base64, '+/', '-_'), '=');
|
||||
|
||||
$processor = $this->createProcessor($urlSafe);
|
||||
|
||||
// Act
|
||||
|
||||
$decodedPayload = $processor->getDecodedPayload();
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertNotNull($decodedPayload);
|
||||
$this->assertSame('GET', $decodedPayload['method']);
|
||||
}
|
||||
|
||||
public function test_it_searches_other_applications_when_route_not_in_current_app(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
...$this->basePayload(),
|
||||
'method' => 'GET',
|
||||
'endpoint' => '/other-prefix/users',
|
||||
]);
|
||||
|
||||
// Mock config to return an application with a different prefix
|
||||
// The route won't exist in current app and won't be found in other apps either
|
||||
// because there are no actual routes defined
|
||||
$this->configMock
|
||||
->shouldReceive('get')
|
||||
->with('nimbus.applications', [])
|
||||
->andReturn([
|
||||
'other-app' => [
|
||||
'routes' => [
|
||||
'prefix' => 'other-prefix',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process($payload);
|
||||
|
||||
// Assert
|
||||
|
||||
// Since no routes are defined anywhere, routeExists will be false
|
||||
$this->assertFalse($processor->routeExists());
|
||||
$this->assertNull($processor->getTargetApplication());
|
||||
}
|
||||
|
||||
public function test_it_returns_null_for_target_application_when_route_in_current_app(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->createMinimalPayload(method: 'GET', endpoint: '/api/test');
|
||||
$processor = $this->createProcessor($payload);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertNull($processor->getTargetApplication());
|
||||
}
|
||||
|
||||
public function test_it_handles_empty_json_decode(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$this->expectNoApplicationLookup();
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Create payload that decodes to null
|
||||
$nullJson = gzcompress('null');
|
||||
$encoded = base64_encode($nullJson);
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process($encoded);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertTrue($processor->hasPayload());
|
||||
$this->assertSame([], $processor->getDecodedPayload());
|
||||
}
|
||||
|
||||
public function test_it_handles_application_config_without_routes_key(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
...$this->basePayload(),
|
||||
'method' => 'GET',
|
||||
'endpoint' => '/api/non-existent',
|
||||
]);
|
||||
|
||||
$this->configMock
|
||||
->shouldReceive('get')
|
||||
->with('nimbus.applications', [])
|
||||
->andReturn([
|
||||
'app-without-routes' => [
|
||||
'name' => 'Test App',
|
||||
],
|
||||
]);
|
||||
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process($payload);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertFalse($processor->routeExists());
|
||||
$this->assertNull($processor->getTargetApplication());
|
||||
}
|
||||
|
||||
public function test_frontend_state_includes_error_when_present(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$this->expectNoApplicationLookup();
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process('invalid-payload');
|
||||
$frontendState = $processor->toFrontendState();
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertArrayHasKey('error', $frontendState);
|
||||
$this->assertNotNull($frontendState['error']);
|
||||
$this->assertNull($frontendState['payload']);
|
||||
$this->assertFalse($frontendState['routeExists']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a processor with the given encoded payload and processes it.
|
||||
*/
|
||||
private function createProcessor(string $encodedPayload): ShareableLinkProcessorService
|
||||
{
|
||||
$this->expectNoApplicationLookup();
|
||||
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
$processor->process($encodedPayload);
|
||||
|
||||
return $processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encoded minimal payload with optional method and endpoint overrides.
|
||||
*/
|
||||
private function createMinimalPayload(string $method = 'GET', string $endpoint = '/api/test'): string
|
||||
{
|
||||
return $this->encodePayload([
|
||||
...$this->basePayload(),
|
||||
'method' => $method,
|
||||
'endpoint' => $endpoint,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base payload structure with default values.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function basePayload(): array
|
||||
{
|
||||
return [
|
||||
'headers' => [],
|
||||
'queryParameters' => [],
|
||||
'body' => [],
|
||||
'payloadType' => 'empty',
|
||||
'authorization' => ['type' => 'none'],
|
||||
];
|
||||
}
|
||||
|
||||
private function expectNoApplicationLookup(): void
|
||||
{
|
||||
$this->configMock
|
||||
->shouldReceive('get')
|
||||
->with('nimbus.applications', [])
|
||||
->andReturn([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a payload using zlib compression matching frontend's pako.deflate().
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function encodePayload(array $data): string
|
||||
{
|
||||
$json = json_encode($data);
|
||||
$compressed = gzcompress($json);
|
||||
$base64 = base64_encode($compressed);
|
||||
|
||||
return rtrim(strtr($base64, '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
public function test_it_returns_early_when_route_exists_in_current_app(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->createMinimalPayload(method: 'GET', endpoint: '/api/test');
|
||||
$processor = $this->createProcessor($payload);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertTrue($processor->routeExists());
|
||||
$this->assertNull($processor->getTargetApplication());
|
||||
}
|
||||
|
||||
public function test_it_sets_target_application_when_route_found_in_other_app(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
...$this->basePayload(),
|
||||
'method' => 'POST',
|
||||
'endpoint' => '/admin/users',
|
||||
]);
|
||||
|
||||
$this->configMock->shouldReceive('get')
|
||||
->with('nimbus.applications', [])
|
||||
->andReturn([
|
||||
'admin' => [
|
||||
'routes' => ['prefix' => '/admin'],
|
||||
],
|
||||
]);
|
||||
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process($payload);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertTrue($processor->routeExists());
|
||||
$this->assertEquals('admin', $processor->getTargetApplication());
|
||||
}
|
||||
|
||||
public function test_it_returns_false_when_prefix_does_not_match(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = $this->encodePayload([
|
||||
...$this->basePayload(),
|
||||
'method' => 'GET',
|
||||
'endpoint' => '/wrong/prefix',
|
||||
]);
|
||||
|
||||
$this->configMock->shouldReceive('get')
|
||||
->with('nimbus.applications', [])
|
||||
->andReturn([
|
||||
'admin' => [
|
||||
'routes' => ['prefix' => '/admin'],
|
||||
],
|
||||
]);
|
||||
|
||||
$processor = new ShareableLinkProcessorService($this->configMock);
|
||||
|
||||
// Act
|
||||
|
||||
$processor->process($payload);
|
||||
|
||||
// Assert
|
||||
|
||||
$this->assertFalse($processor->routeExists());
|
||||
$this->assertNull($processor->getTargetApplication());
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,13 @@ export class BasePage {
|
||||
await this.executeRequest();
|
||||
}
|
||||
|
||||
async addHeader(key: string, value: string, index: number = 0) {
|
||||
await this.page
|
||||
.getByTestId('request-builder-root')
|
||||
.getByRole('tab', { name: 'Headers' })
|
||||
.click();
|
||||
async addHeader(key: string, value: string, index: number = 0, skipTabNavigation: boolean = false) {
|
||||
if (!skipTabNavigation) {
|
||||
await this.page
|
||||
.getByTestId('request-builder-root')
|
||||
.getByRole('tab', { name: 'Headers' })
|
||||
.click();
|
||||
}
|
||||
|
||||
await this.page
|
||||
.getByTestId('request-headers')
|
||||
@@ -66,26 +68,28 @@ export class BasePage {
|
||||
return { headerKey, headerValue };
|
||||
}
|
||||
|
||||
async addQueryParameter(key: string, value: string) {
|
||||
await this.page
|
||||
.getByTestId('request-builder-root')
|
||||
.getByRole('tab', { name: 'Parameters' })
|
||||
.click();
|
||||
async addQueryParameter(key: string, value: string, index: number = 0, skipTabNavigation: boolean = false) {
|
||||
if (!skipTabNavigation) {
|
||||
await this.page
|
||||
.getByTestId('request-builder-root')
|
||||
.getByRole('tab', { name: 'Parameters' })
|
||||
.click();
|
||||
}
|
||||
|
||||
await this.page
|
||||
.getByTestId('request-parameters')
|
||||
.getByRole('button', { name: 'Add' })
|
||||
.click();
|
||||
|
||||
const paramKey = this.page
|
||||
.getByTestId('request-parameters')
|
||||
.getByTestId('kv-key')
|
||||
.first();
|
||||
const paramKey =
|
||||
index === 0
|
||||
? this.page.getByTestId('request-parameters').getByTestId('kv-key').first()
|
||||
: this.page.getByTestId('request-parameters').getByTestId('kv-key').nth(index);
|
||||
|
||||
const paramValue = this.page
|
||||
.getByTestId('request-parameters')
|
||||
.getByTestId('kv-value')
|
||||
.first();
|
||||
const paramValue =
|
||||
index === 0
|
||||
? this.page.getByTestId('request-parameters').getByTestId('kv-value').first()
|
||||
: this.page.getByTestId('request-parameters').getByTestId('kv-value').nth(index);
|
||||
|
||||
await paramKey.fill(key);
|
||||
await paramValue.fill(value);
|
||||
|
||||
@@ -13,15 +13,13 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 120_000,
|
||||
timeout: 160_000,
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 8,
|
||||
workers: process.env.CI ? 4 : 8,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
|
||||
@@ -114,7 +114,7 @@ test("Dump and Die visualization sanity checklist", async ({ page }) => {
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator("#reka-collapsible-content-v-126"),
|
||||
page.locator("#reka-collapsible-content-v-127"),
|
||||
).toMatchAriaSnapshot(`- text: "0: \\"/\\" (1) 1: \\"\\\\\\" (1)"`);
|
||||
|
||||
// dump #3 (runtime object)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from '../core/fixtures';
|
||||
|
||||
|
||||
test.describe('Search Functionality', () => {
|
||||
|
||||
152
tests/E2E/tests/link-sharing.spec.ts
Normal file
152
tests/E2E/tests/link-sharing.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { test, expect } from '../core/fixtures';
|
||||
|
||||
test('Link Sharing complete workflow', async ({ page, basePage }) => {
|
||||
// Arrange
|
||||
|
||||
await basePage.goto();
|
||||
|
||||
// Act - Pick an endpoint and navigate to it
|
||||
|
||||
await page.getByRole('button', { name: 'shapes' }).click();
|
||||
await page.getByRole('button', { name: 'POST /nested-object' }).click();
|
||||
|
||||
// Act - Add query parameters
|
||||
|
||||
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Parameters' }).click();
|
||||
|
||||
const { paramKey: param1Key, paramValue: param1Value } = await basePage.addQueryParameter('param1', 'value1', 0, true);
|
||||
const { paramKey: param2Key, paramValue: param2Value } = await basePage.addQueryParameter('param2', 'value2', 1, true);
|
||||
|
||||
// Act - Add JSON payload
|
||||
|
||||
await page.getByRole('tab', { name: 'Body' }).click();
|
||||
await page.getByRole('button', { name: 'Auto Fill' }).click();
|
||||
|
||||
const bodyEditor = page.getByTestId('request-builder-root').locator('.cm-content');
|
||||
await expect(bodyEditor).toContainText('"name"');
|
||||
|
||||
// Act - Add Bearer token authorization
|
||||
|
||||
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Authorization' }).click();
|
||||
|
||||
// Select Bearer token from the authorization type dropdown
|
||||
await page.getByTestId('request-authorization').getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Bearer Token' }).click();
|
||||
|
||||
// Set the Bearer token value
|
||||
await page.getByPlaceholder('Token').fill('test-bearer-token-12345');
|
||||
|
||||
// Act - Add custom headers
|
||||
|
||||
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Headers' }).click();
|
||||
|
||||
const { headerKey: header1Key, headerValue: header1Value } = await basePage.addHeader('X-Custom-Header-1', 'custom-value-1', 0, true);
|
||||
const { headerKey: header2Key, headerValue: header2Value } = await basePage.addHeader('X-Custom-Header-2', 'custom-value-2', 1, true);
|
||||
|
||||
// Act - Execute the request
|
||||
|
||||
await basePage.executeRequest();
|
||||
|
||||
// Assert - Verify response was received
|
||||
await expect(page.getByTestId('response-status-badge')).toContainText('201');
|
||||
|
||||
// Act - Copy the shareable link
|
||||
|
||||
await page.getByTestId('request-options-button').click();
|
||||
await page.getByTestId('copy-shareable-link-option').click();
|
||||
|
||||
// Wait for the shareable link dialog to appear
|
||||
await expect(page.getByTestId('shareable-link-content')).toBeVisible();
|
||||
|
||||
// Extract the shareable link
|
||||
const shareableLink = await page.getByTestId('shareable-link-content').textContent();
|
||||
|
||||
// Close the dialog
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Act - Clear browser storage and refresh
|
||||
|
||||
// Navigate to a neutral page first to ensure store doesn't re-persist currently active route
|
||||
await page.goto('/demo');
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
// Assert - Verify state is empty
|
||||
|
||||
await expect(page.getByTestId('response-empty')).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: '<endpoint>' })).toHaveValue('');
|
||||
|
||||
// Act - Navigate to the copied shared link
|
||||
|
||||
if (!shareableLink) {
|
||||
throw new Error('Shareable link was not extracted');
|
||||
}
|
||||
|
||||
await page.goto(shareableLink);
|
||||
|
||||
// Assert - Verify toast notification is shown
|
||||
|
||||
await expect(page.getByText('Shared Request Restored')).toBeVisible();
|
||||
await expect(page.getByText('Request and response have been imported from the shareable link.')).toBeVisible();
|
||||
|
||||
// Assert - Verify notification is shown
|
||||
|
||||
await expect(page.getByTestId('imported-badge')).toBeVisible();
|
||||
|
||||
// Assert - Verify endpoint is populated
|
||||
|
||||
await expect(page.getByRole('textbox', { name: '<endpoint>' })).toHaveValue('_demo/shapes/nested-object');
|
||||
|
||||
// Assert - Verify request tabs are populated properly
|
||||
|
||||
// Parameters tab
|
||||
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Parameters' }).click();
|
||||
await expect(page.getByTestId('request-parameters').getByTestId('kv-key').first()).toHaveValue('param1');
|
||||
await expect(page.getByTestId('request-parameters').getByTestId('kv-value').first()).toHaveValue('value1');
|
||||
await expect(page.getByTestId('request-parameters').getByTestId('kv-key').nth(1)).toHaveValue('param2');
|
||||
await expect(page.getByTestId('request-parameters').getByTestId('kv-value').nth(1)).toHaveValue('value2');
|
||||
|
||||
// Body tab
|
||||
await page.getByRole('tab', { name: 'Body' }).click();
|
||||
await expect(bodyEditor).toContainText('"name"');
|
||||
|
||||
// Authorization tab
|
||||
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Authorization' }).click();
|
||||
|
||||
// Verify Bearer Token is selected
|
||||
await expect(page.getByTestId('request-authorization').getByRole('combobox')).toHaveText('Bearer Token');
|
||||
|
||||
// Verify token value is restored
|
||||
await expect(page.getByPlaceholder('Token')).toHaveValue('test-bearer-token-12345');
|
||||
|
||||
// Headers tab
|
||||
await page.getByTestId('request-builder-root').getByRole('tab', { name: 'Headers' }).click();
|
||||
await expect(page.getByTestId('request-headers').getByTestId('kv-key').first()).toHaveValue('X-Custom-Header-1');
|
||||
await expect(page.getByTestId('request-headers').getByTestId('kv-value').first()).toHaveValue('custom-value-1');
|
||||
await expect(page.getByTestId('request-headers').getByTestId('kv-key').nth(1)).toHaveValue('X-Custom-Header-2');
|
||||
await expect(page.getByTestId('request-headers').getByTestId('kv-value').nth(1)).toHaveValue('custom-value-2');
|
||||
|
||||
// Assert - Verify response tabs are populated properly
|
||||
|
||||
await expect(page.getByTestId('response-status-badge')).toContainText('201');
|
||||
await expect(page.getByTestId('response-status-duration')).not.toHaveText('0ms');
|
||||
await expect(page.getByTestId('response-status-size')).not.toHaveText('0B');
|
||||
|
||||
// Response tab
|
||||
await page.getByTestId('response-content').getByRole('tab', { name: 'Response' }).click();
|
||||
await expect(page.getByTestId('response-content')).toContainText('"data"');
|
||||
|
||||
// Headers tab
|
||||
await page.getByTestId('response-content').getByRole('tab', { name: 'Headers' }).click();
|
||||
await expect(page.getByTestId('response-content')).toBeVisible();
|
||||
|
||||
// Cookies tab
|
||||
await page.getByTestId('response-content').getByRole('tab', { name: 'Cookies' }).click();
|
||||
await expect(page.getByTestId('response-content')).toBeVisible();
|
||||
});
|
||||
@@ -7,6 +7,8 @@ use Illuminate\Support\Facades\Vite;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Sunchayn\Nimbus\Http\Web\Controllers\NimbusIndexController;
|
||||
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
|
||||
use Sunchayn\Nimbus\Modules\Export\Services\ShareableLinkProcessorService;
|
||||
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildCurrentUserAction;
|
||||
use Sunchayn\Nimbus\Modules\Routes\Actions\BuildGlobalHeadersAction;
|
||||
use Sunchayn\Nimbus\Modules\Routes\Actions\DisableThirdPartyUiAction;
|
||||
@@ -50,6 +52,7 @@ class NimbusIndexTest extends TestCase
|
||||
$buildGlobalHeadersActionMock = $this->mock(BuildGlobalHeadersAction::class);
|
||||
$buildCurrentUserActionMock = $this->mock(BuildCurrentUserAction::class);
|
||||
$extractRoutesActionMock = $this->mock(ExtractRoutesAction::class);
|
||||
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
|
||||
|
||||
// Anticipate
|
||||
|
||||
@@ -57,6 +60,10 @@ class NimbusIndexTest extends TestCase
|
||||
|
||||
$buildCurrentUserActionMock->shouldReceive('execute')->andReturn(['::current-user::']);
|
||||
|
||||
$shareableLinkProcessorMock->shouldReceive('process')->andReturnSelf();
|
||||
$shareableLinkProcessorMock->shouldReceive('getTargetApplication')->andReturnNull();
|
||||
$shareableLinkProcessorMock->shouldReceive('toFrontendState')->andReturnNull();
|
||||
|
||||
$extractedRoutesCollectionStub = new class($expectedApplicationKey) extends ExtractedRoutesCollection
|
||||
{
|
||||
public function __construct(private string $key) {}
|
||||
@@ -85,6 +92,8 @@ class NimbusIndexTest extends TestCase
|
||||
|
||||
$response->assertViewHas('currentUser', ['::current-user::']);
|
||||
|
||||
$response->assertViewHas('sharedState', null);
|
||||
|
||||
$response->assertViewHas('activeApplicationResolver', function ($resolver) use ($expectedApplicationKey) {
|
||||
return $resolver->getActiveApplicationKey() === $expectedApplicationKey;
|
||||
});
|
||||
@@ -146,6 +155,7 @@ class NimbusIndexTest extends TestCase
|
||||
$ignoreRoutesServiceSpy = $this->spy(IgnoredRoutesService::class);
|
||||
$buildGlobalHeadersActionSpy = $this->spy(BuildGlobalHeadersAction::class);
|
||||
$buildCurrentUserActionSpy = $this->spy(BuildCurrentUserAction::class);
|
||||
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
|
||||
|
||||
$extractionRoutesActionMock = $this->mock(ExtractRoutesAction::class);
|
||||
|
||||
@@ -153,6 +163,10 @@ class NimbusIndexTest extends TestCase
|
||||
|
||||
// Anticipate
|
||||
|
||||
$shareableLinkProcessorMock->shouldReceive('process')->andReturnSelf();
|
||||
$shareableLinkProcessorMock->shouldReceive('getTargetApplication')->andReturnNull();
|
||||
$shareableLinkProcessorMock->shouldReceive('toFrontendState')->andReturnNull();
|
||||
|
||||
$extractionRoutesActionMock->shouldReceive('execute')->andThrow($exception);
|
||||
|
||||
// Act
|
||||
@@ -222,4 +236,223 @@ class NimbusIndexTest extends TestCase
|
||||
'new-app',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_processes_valid_shareable_link(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = [
|
||||
'method' => 'GET',
|
||||
'endpoint' => '/api/test',
|
||||
'headers' => [],
|
||||
'queryParameters' => [],
|
||||
'body' => [],
|
||||
'payloadType' => 'empty',
|
||||
'authorization' => ['type' => 'none'],
|
||||
];
|
||||
|
||||
$encoded = base64_encode(json_encode($payload)); // <- this is dummy payload, we are mocking the interaction.
|
||||
|
||||
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
|
||||
|
||||
// Anticipate
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('process')
|
||||
->with($encoded)
|
||||
->andReturnSelf();
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('getTargetApplication')
|
||||
->andReturnNull();
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('toFrontendState')
|
||||
->andReturn([
|
||||
'payload' => $payload,
|
||||
'routeExists' => true,
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
// Act
|
||||
|
||||
$response = $this->get(route('nimbus.index', ['share' => $encoded]));
|
||||
|
||||
// Assert
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$response->assertViewHas('sharedState', [
|
||||
'payload' => $payload,
|
||||
'routeExists' => true,
|
||||
'error' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_handles_shareable_link_with_error(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$invalidPayload = 'invalid-base64-string';
|
||||
|
||||
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
|
||||
|
||||
// Anticipate
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('process')
|
||||
->with($invalidPayload)
|
||||
->andReturnSelf();
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('getTargetApplication')
|
||||
->andReturnNull();
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('toFrontendState')
|
||||
->andReturn([
|
||||
'payload' => null,
|
||||
'routeExists' => false,
|
||||
'error' => 'Failed to decode base64 payload',
|
||||
]);
|
||||
|
||||
// Act
|
||||
|
||||
$response = $this->get(route('nimbus.index', ['share' => $invalidPayload]));
|
||||
|
||||
// Assert
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$response->assertViewHas('sharedState', function ($state) {
|
||||
return $state['error'] === 'Failed to decode base64 payload'
|
||||
&& $state['routeExists'] === false
|
||||
&& $state['payload'] === null;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_redirects_when_shareable_link_targets_different_application(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = [
|
||||
'method' => 'GET',
|
||||
'endpoint' => '/api/test',
|
||||
'headers' => [],
|
||||
'queryParameters' => [],
|
||||
'body' => [],
|
||||
'payloadType' => 'empty',
|
||||
'authorization' => ['type' => 'none'],
|
||||
'applicationKey' => 'other-app',
|
||||
];
|
||||
|
||||
$encoded = base64_encode(json_encode($payload)); // <- this is dummy payload, we are mocking the interaction.
|
||||
|
||||
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
|
||||
|
||||
$activeApplicationResolverMock = $this->mock(ActiveApplicationResolver::class);
|
||||
|
||||
// Anticipate
|
||||
|
||||
$activeApplicationResolverMock
|
||||
->shouldReceive('getActiveApplicationKey')
|
||||
->andReturn('main-app');
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('process')
|
||||
->with($encoded)
|
||||
->andReturnSelf();
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('getTargetApplication')
|
||||
->andReturn('other-app');
|
||||
|
||||
// Act
|
||||
|
||||
$response = $this->get(route('nimbus.index', ['share' => $encoded]));
|
||||
|
||||
// Assert
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$response->assertCookie(
|
||||
\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::CURRENT_APPLICATION_COOKIE_NAME,
|
||||
'other-app',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_does_not_redirect_when_shareable_link_targets_same_application(): void
|
||||
{
|
||||
// Arrange
|
||||
|
||||
$payload = [
|
||||
'method' => 'GET',
|
||||
'endpoint' => '/api/test',
|
||||
'headers' => [],
|
||||
'queryParameters' => [],
|
||||
'body' => [],
|
||||
'payloadType' => 'empty',
|
||||
'authorization' => ['type' => 'none'],
|
||||
'applicationKey' => 'main-app',
|
||||
];
|
||||
|
||||
$encoded = base64_encode(json_encode($payload));
|
||||
|
||||
$disableThirdPartyUiActionSpy = $this->spy(DisableThirdPartyUiAction::class);
|
||||
$buildGlobalHeadersActionMock = $this->mock(BuildGlobalHeadersAction::class);
|
||||
$buildCurrentUserActionMock = $this->mock(BuildCurrentUserAction::class);
|
||||
$extractRoutesActionMock = $this->mock(ExtractRoutesAction::class);
|
||||
$shareableLinkProcessorMock = $this->mock(ShareableLinkProcessorService::class);
|
||||
$activeApplicationResolverMock = $this->mock(ActiveApplicationResolver::class);
|
||||
|
||||
// Anticipate
|
||||
|
||||
$activeApplicationResolverMock
|
||||
->shouldReceive('getActiveApplicationKey')
|
||||
->andReturn('main-app');
|
||||
|
||||
$activeApplicationResolverMock->shouldReceive('isVersioned')->andReturn(false);
|
||||
$activeApplicationResolverMock->shouldReceive('getApiBaseUrl')->andReturn('http://localhost');
|
||||
$activeApplicationResolverMock->shouldReceive('getAvailableApplications')->andReturn('{}');
|
||||
|
||||
$buildGlobalHeadersActionMock->shouldReceive('execute')->andReturn(['::global-headers::']);
|
||||
$buildCurrentUserActionMock->shouldReceive('execute')->andReturn(['::current-user::']);
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('process')
|
||||
->with($encoded)
|
||||
->andReturnSelf();
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('getTargetApplication')
|
||||
->andReturn('main-app');
|
||||
|
||||
$shareableLinkProcessorMock
|
||||
->shouldReceive('toFrontendState')
|
||||
->andReturn([
|
||||
'payload' => $payload,
|
||||
'routeExists' => true,
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
$extractRoutesActionMock->shouldReceive('execute')->andReturn($this->mock(ExtractedRoutesCollection::class)->shouldReceive('toFrontendArray')->andReturn([])->getMock());
|
||||
|
||||
// Act
|
||||
|
||||
$response = $this->get(route('nimbus.index', ['share' => $encoded]));
|
||||
|
||||
// Assert
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$response->assertViewIs('nimbus::app');
|
||||
|
||||
$response->assertViewHas('sharedState', [
|
||||
'payload' => $payload,
|
||||
'routeExists' => true,
|
||||
'error' => null,
|
||||
]);
|
||||
|
||||
$disableThirdPartyUiActionSpy->shouldHaveReceived('execute')->once();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ This guide covers everything you need to know about using Nimbus to test and exp
|
||||
- [Value Generators](#value-generators)
|
||||
- [Auto-Fill Payloads](#auto-fill-payloads)
|
||||
- [Export to cURL](#export-to-curl)
|
||||
- [Shareable Links](#shareable-links)
|
||||
- [Configuration](#configuration)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Getting Help](#getting-help)
|
||||
@@ -353,6 +354,33 @@ Export configured requests as cURL commands for use in terminals or scripts.
|
||||
- Request body
|
||||
- HTTP method
|
||||
|
||||
### Shareable Links
|
||||
|
||||
Shareable links allow you to capture the exact state of your Request Builder and share it with others. This is perfect for bug reports, API demonstrations, or collaborating on complex request configurations.
|
||||
|
||||
**Capture everything:**
|
||||
- HTTP Method and Endpoint
|
||||
- Headers and Query Parameters
|
||||
- Request Body & Payload Type
|
||||
- Authentication settings
|
||||
- Last response metadata (status, duration, and size)
|
||||
|
||||
**How to use:**
|
||||
1. Configure your request (and optionally execute it to include response context).
|
||||
2. Click the **Request Options** (sparkles) icon in the endpoint bar.
|
||||
3. Select **Copy Shareable Link**.
|
||||
4. Share the generated URL.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**Automatic Restoration:**
|
||||
When a shareable link is opened, Nimbus will:
|
||||
- Parse and decompress the payload.
|
||||
- Automatically restore all request data.
|
||||
- **Switch Applications**: If the link points to a route in a different application (e.g., from `Rest API` to `Admin API`), Nimbus will automatically switch the active application context for you.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
BIN
wiki/user-guide/assets/shareable-link-2.png
Normal file
BIN
wiki/user-guide/assets/shareable-link-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 692 KiB |
BIN
wiki/user-guide/assets/shareable-link.png
Normal file
BIN
wiki/user-guide/assets/shareable-link.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
Reference in New Issue
Block a user