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:
Mazen Touati
2026-01-24 03:01:32 +01:00
committed by GitHub
parent 106bba7539
commit 2895a0ddc6
40 changed files with 2401 additions and 190 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -1 +1 @@
export { default as AppSonner } from './Sonner.vue';
export { default as AppSonner } from './AppSonner.vue';

View File

@@ -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>

View File

@@ -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>

View File

@@ -158,7 +158,6 @@ const resetClearConfirmation = () => {
<small class="text-subtle text-xs" :title="absoluteTime">
{{ readableTime }}
</small>
<HistoryIcon class="size-3" />
</AppButton>
</AppDropdownMenuTrigger>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 => {

View 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,
};
}

View File

@@ -7,6 +7,7 @@ export interface RequestLog {
request: Request;
response?: Response;
error?: ErrorPlainResponse;
importedFromShare?: boolean;
}
export type RequestLogRef = Ref<RequestLog | null>;

View 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;
}

View File

@@ -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.
*/

View File

@@ -4,3 +4,4 @@
export { useConfigStore } from './useConfigStore';
export { useSettingsStore } from './useSettingsStore';
export { useSharedStateStore } from './useSharedStateStore';

View 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,
};
});

View File

@@ -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';

View File

@@ -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,
};
},
{

View File

@@ -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',
}),
);
});
});

View File

@@ -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}');
});
});

View File

@@ -31,6 +31,7 @@ describe('useConfigStore', () => {
routeExtractorException: null,
applications: JSON.stringify({ main: 'Main API' }),
activeApplication: 'main',
sharedState: null,
};
const store = useConfigStore();

View 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');
});
});
});

View 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}`;
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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(),

View 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);
}
}

View File

@@ -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());
}
}

View File

@@ -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);

View File

@@ -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. */

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
import { test, expect } from './fixtures';
import { test, expect } from '../core/fixtures';
test.describe('Search Functionality', () => {

View 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();
});

View File

@@ -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();
}
}

View File

@@ -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.
![Shareable link entry point](./assets/shareable-link.png)
![Shareable link example](./assets/shareable-link-2.png)
**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

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB