fix (common): address mock server issues and improve the UI (#5517)

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
This commit is contained in:
Anwarul Islam 2025-10-29 16:55:02 +06:00 committed by GitHub
parent 213c5436bc
commit c0e3ff49b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1136 additions and 321 deletions

View file

@ -18,10 +18,10 @@ import {
import { WorkspaceType } from 'src/types/WorkspaceTypes';
// Regex pattern for mock server name validation
// Allows letters, numbers, spaces, dots, underscores, and hyphens
const MOCK_SERVER_NAME_PATTERN = /^[a-zA-Z0-9 ._-]+$/;
// Allows letters, numbers, spaces, dots, brackets, underscores, and hyphens
const MOCK_SERVER_NAME_PATTERN = /^[a-zA-Z0-9 .()[\]{}<>_-]+$/;
const MOCK_SERVER_NAME_ERROR_MESSAGE =
'Name can only contain letters, numbers, spaces, dots, underscores, and hyphens';
'Name can only contain letters, numbers, spaces, dots, brackets, underscores, and hyphens';
@ObjectType()
export class MockServer {

View file

@ -55,11 +55,12 @@ export class MockServerResolver {
}
@ResolveField(() => MockServerCollection, {
nullable: true,
description: 'Returns the collection of the mock server',
})
async collection(
@Parent() mockServer: MockServer,
): Promise<MockServerCollection> {
): Promise<MockServerCollection | null> {
const collection = await this.mockServerService.getMockServerCollection(
mockServer.id,
);

View file

@ -335,7 +335,7 @@ describe('MockServerService', () => {
}
});
test('should return error when collection not found', async () => {
test('should return null when collection not found', async () => {
mockPrisma.mockServer.findUnique.mockResolvedValue(dbMockServer);
mockPrisma.userCollection.findUnique.mockResolvedValue(null);
@ -343,9 +343,9 @@ describe('MockServerService', () => {
dbMockServer.id,
);
expect(E.isLeft(result)).toBe(true);
if (E.isLeft(result)) {
expect(result.left).toBe(MOCK_SERVER_INVALID_COLLECTION);
expect(E.isRight(result)).toBe(true);
if (E.isRight(result)) {
expect(result.right).toBe(null);
}
});
});
@ -863,6 +863,7 @@ describe('MockServerService', () => {
} as any;
test('should return example by ID header', async () => {
mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection);
mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections
mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any);
@ -882,6 +883,7 @@ describe('MockServerService', () => {
});
test('should return example by name header', async () => {
mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection);
mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections
mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any);
@ -915,6 +917,7 @@ describe('MockServerService', () => {
},
};
mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection);
mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections
mockPrisma.userRequest.findMany.mockResolvedValue([
requestWith404,
@ -936,6 +939,7 @@ describe('MockServerService', () => {
});
test('should match exact path', async () => {
mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection);
mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any);
mockPrisma.userCollection.findMany.mockResolvedValue([]);
@ -964,6 +968,7 @@ describe('MockServerService', () => {
},
};
mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection);
mockPrisma.userRequest.findMany.mockResolvedValue([
variableRequest,
] as any);
@ -979,6 +984,7 @@ describe('MockServerService', () => {
});
test('should return error when no examples found', async () => {
mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection);
mockPrisma.userRequest.findMany.mockResolvedValue([]);
mockPrisma.userCollection.findMany.mockResolvedValue([]);
@ -1004,6 +1010,7 @@ describe('MockServerService', () => {
},
};
mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection);
mockPrisma.userRequest.findMany.mockResolvedValue([
multipleExamples,
] as any);
@ -1036,6 +1043,7 @@ describe('MockServerService', () => {
},
};
mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection);
mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections
mockPrisma.userRequest.findMany.mockResolvedValue([simpleRequest] as any);
@ -1070,6 +1078,7 @@ describe('MockServerService', () => {
},
};
mockPrisma.teamCollection.findUnique.mockResolvedValue(teamCollection);
mockPrisma.teamCollection.findMany.mockResolvedValue([]); // No child collections
mockPrisma.teamRequest.findMany.mockResolvedValue([teamRequest] as any);

View file

@ -222,7 +222,7 @@ export class MockServerService {
const collection = await this.prisma.userCollection.findUnique({
where: { id: mockServer.collectionID },
});
if (!collection) return E.left(MOCK_SERVER_INVALID_COLLECTION);
if (!collection) return E.right(null);
return E.right({
id: collection.id,
title: collection.title,
@ -231,7 +231,7 @@ export class MockServerService {
const collection = await this.prisma.teamCollection.findUnique({
where: { id: mockServer.collectionID },
});
if (!collection) return E.left(MOCK_SERVER_INVALID_COLLECTION);
if (!collection) return E.right(null);
return E.right({
id: collection.id,
title: collection.title,
@ -599,6 +599,12 @@ export class MockServerService {
// This is used by both custom header lookup and candidate fetching
const collectionIds = await this.getCollectionIds(mockServer);
if (collectionIds.length === 0) {
return E.left(
`The collection associated with this mock has been deleted.`,
);
}
// OPTIMIZATION: Fetch all requests with examples once (single DB query)
// This is shared between custom header lookup and candidate matching
const requests = await this.fetchRequestsWithExamples(
@ -848,6 +854,13 @@ export class MockServerService {
private async getAllUserCollectionIds(
rootCollectionId: string,
): Promise<string[]> {
// First verify the root collection exists
const rootCollection = await this.prisma.userCollection.findUnique({
where: { id: rootCollectionId },
});
if (!rootCollection) return []; // Collection doesn't exist
const ids = [rootCollectionId];
const children = await this.prisma.userCollection.findMany({
where: { parentID: rootCollectionId },
@ -868,6 +881,13 @@ export class MockServerService {
private async getAllTeamCollectionIds(
rootCollectionId: string,
): Promise<string[]> {
// First verify the root collection exists
const rootCollection = await this.prisma.teamCollection.findUnique({
where: { id: rootCollectionId },
});
if (!rootCollection) return []; // Collection doesn't exist
const ids = [rootCollectionId];
const children = await this.prisma.teamCollection.findMany({
where: { parentID: rootCollectionId },

View file

@ -855,6 +855,7 @@
"authentication": "Authentication"
},
"mock_server": {
"confirm_delete_log": "Are you sure you want to delete this log?",
"create_mock_server": "Configure Mock Server",
"mock_server_configuration": "Mock Server Configuration",
"mock_server_name": "Mock Server Name",
@ -885,6 +886,7 @@
"private_description": "Only authenticated users can access this mock server",
"select_collection": "Select a collection",
"select_collection_error": "Please select a collection",
"invalid_collection_error": "Failed to create a mock server for the collection.",
"url_copied": "URL copied to clipboard",
"make_public": "Make Public",
"view_logs": "View logs",
@ -1079,6 +1081,7 @@
"ai_request_naming_style_custom": "Custom",
"ai_request_naming_style_custom_placeholder": "Enter your custom naming style template...",
"experimental_scripting_sandbox": "Experimental scripting sandbox",
"enable_experimental_mock_servers": "Enable Mock Servers",
"sync": "Synchronise",
"sync_collections": "Collections",
"sync_description": "These settings are synced to cloud.",
@ -1375,6 +1378,7 @@
"loading": "Loading...",
"message_received": "Message: {message} arrived on topic: {topic}",
"mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}",
"no_content_found": "No content found",
"none": "None",
"nothing_found": "Nothing found for",
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",

View file

@ -214,9 +214,11 @@ declare module 'vue' {
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheck: typeof import('~icons/lucide/check')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
IconLucideCopy: typeof import('~icons/lucide/copy')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
@ -260,6 +262,7 @@ declare module 'vue' {
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
MockServerCreateMockServer: typeof import('./components/mockServer/CreateMockServer.vue')['default']
MockServerEditMockServer: typeof import('./components/mockServer/EditMockServer.vue')['default']
MockServerLogSection: typeof import('./components/mockServer/LogSection.vue')['default']
MockServerMockServerDashboard: typeof import('./components/mockServer/MockServerDashboard.vue')['default']
MockServerMockServerLogs: typeof import('./components/mockServer/MockServerLogs.vue')['default']
MonacoScriptEditor: typeof import('./components/MonacoScriptEditor.vue')['default']

View file

@ -135,7 +135,10 @@
@keyup.p="propertiesAction?.$el.click()"
@keyup.t="runCollectionAction?.$el.click()"
@keyup.s="sortAction?.$el.click()"
@keyup.m="mockServerAction?.$el.click()"
@keyup.m="
ENABLE_EXPERIMENTAL_MOCK_SERVERS &&
mockServerAction?.$el.click()
"
@keyup.escape="hide()"
>
<HoppSmartItem
@ -177,7 +180,11 @@
"
/>
<HoppSmartItem
v-if="!hasNoTeamAccess && isRootCollection"
v-if="
!hasNoTeamAccess &&
isRootCollection &&
ENABLE_EXPERIMENTAL_MOCK_SERVERS
"
ref="mockServerAction"
:icon="IconServer"
:label="t('mock_server.create_mock_server')"
@ -321,6 +328,7 @@ import IconArrowUpDown from "~icons/lucide/arrow-up-down"
import { CurrentSortValuesService } from "~/services/current-sort.service"
import { useService } from "dioc/vue"
import { useMockServerStatus } from "~/composables/mockServer"
import { useSetting } from "@composables/settings"
import { platform } from "~/platform"
import { invokeAction } from "~/helpers/actions"
@ -456,9 +464,16 @@ const isCollectionLoading = computed(() => {
})
// Mock Server Status
const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting(
"ENABLE_EXPERIMENTAL_MOCK_SERVERS"
)
const { getMockServerStatus } = useMockServerStatus()
const mockServerStatus = computed(() => {
if (!ENABLE_EXPERIMENTAL_MOCK_SERVERS.value) {
return { exists: false, isActive: false }
}
const collectionId =
props.collectionsType === "my-collections"
? (props.data as HoppCollection).id

View file

@ -33,7 +33,11 @@ import { defineStep } from "~/composables/step-components"
import AllCollectionImport from "~/components/importExport/ImportExportSteps/AllCollectionImport.vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import {
appendRESTCollections,
restCollections$,
setRESTCollections,
} from "~/newstore/collections"
import IconInsomnia from "~icons/hopp/insomnia"
import IconPostman from "~icons/hopp/postman"
@ -46,6 +50,11 @@ import { useReadonlyStream } from "~/composables/stream"
import IconUser from "~icons/lucide/user"
import { getTeamCollectionJSON } from "~/helpers/backend/helpers"
import {
importUserCollectionsFromJSON,
fetchAndConvertUserCollections,
} from "~/helpers/backend/mutations/UserCollection"
import { ReqType } from "~/helpers/backend/graphql"
import { platform } from "~/platform"
@ -101,7 +110,7 @@ const showImportFailedError = () => {
const handleImportToStore = async (collections: HoppCollection[]) => {
const importResult =
props.collectionsType.type === "my-collections"
? importToPersonalWorkspace(collections)
? await importToPersonalWorkspace(collections)
: await importToTeamsWorkspace(collections)
if (E.isRight(importResult)) {
@ -111,11 +120,58 @@ const handleImportToStore = async (collections: HoppCollection[]) => {
}
}
const importToPersonalWorkspace = (collections: HoppCollection[]) => {
appendRESTCollections(collections)
return E.right({
success: true,
})
const importToPersonalWorkspace = async (collections: HoppCollection[]) => {
// If user is logged in, try to import to backend first
if (currentUser.value) {
try {
const transformedCollection = collections.map((collection) =>
translateToPersonalCollectionFormat(collection)
)
const res = await importUserCollectionsFromJSON(
JSON.stringify(transformedCollection),
ReqType.Rest
)()
if (E.isRight(res)) {
// Backend import succeeded, now fetch and persist collections in store
const fetchResult = await fetchAndConvertUserCollections(ReqType.Rest)
if (E.isRight(fetchResult)) {
// Replace local collections with backend collections
setRESTCollections(fetchResult.right)
} else {
console.warn(
"Failed to fetch collections from backend after import:",
fetchResult.left
)
// Still append to local store as fallback
appendRESTCollections(collections)
}
return E.right({ success: true })
}
// Backend import failed, fall back to local storage
console.warn(
"Backend import failed, falling back to local storage:",
res.left
)
appendRESTCollections(collections)
return E.right({ success: true })
} catch (error) {
// Backend import failed, fall back to local storage
console.warn(
"Backend import failed, falling back to local storage:",
error
)
appendRESTCollections(collections)
return E.right({ success: true })
}
} else {
// User not logged in, use local storage
appendRESTCollections(collections)
return E.right({ success: true })
}
}
function translateToTeamCollectionFormat(x: HoppCollection) {
@ -140,6 +196,28 @@ function translateToTeamCollectionFormat(x: HoppCollection) {
return obj
}
function translateToPersonalCollectionFormat(x: HoppCollection) {
const folders: HoppCollection[] = (x.folders ?? []).map(
translateToPersonalCollectionFormat
)
const data = {
auth: x.auth,
headers: x.headers,
variables: x.variables,
}
const obj = {
...x,
folders,
data,
}
if (x.id) obj.id = x.id
return obj
}
const importToTeamsWorkspace = async (collections: HoppCollection[]) => {
if (!hasTeamWriteAccess.value || !selectedTeamID.value) {
return E.left({

View file

@ -154,7 +154,7 @@ import { TeamWorkspace } from "~/services/workspace.service"
import IconSparkle from "~icons/lucide/sparkles"
import IconThumbsDown from "~icons/lucide/thumbs-down"
import IconThumbsUp from "~icons/lucide/thumbs-up"
import { handleTokenValidation } from "~/helpers/handleTokenValidation";
import { handleTokenValidation } from "~/helpers/handleTokenValidation"
const t = useI18n()
const toast = useToast()

View file

@ -18,6 +18,11 @@ import { useToast } from "~/composables/toast"
import { ImporterOrExporter } from "~/components/importExport/types"
import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import {
importUserCollectionsFromJSON,
fetchAndConvertUserCollections,
} from "~/helpers/backend/mutations/UserCollection"
import { ReqType } from "~/helpers/backend/graphql"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconUser from "~icons/lucide/user"
@ -28,6 +33,7 @@ import { platform } from "~/platform"
import {
appendGraphqlCollections,
graphqlCollections$,
setGraphqlCollections,
} from "~/newstore/collections"
import { hoppGqlCollectionsImporter } from "~/helpers/import-export/import/hoppGql"
import { gqlCollectionsExporter } from "~/helpers/import-export/export/gqlCollections"
@ -71,7 +77,7 @@ const GqlCollectionsHoppImporter: ImporterOrExporter = {
)()
if (E.isRight(validatedCollection)) {
handleImportToStore(validatedCollection.right)
await handleImportToStore(validatedCollection.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
@ -110,7 +116,7 @@ const GqlCollectionsGistImporter: ImporterOrExporter = {
return
}
handleImportToStore(res.right)
await handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
@ -231,9 +237,83 @@ const showImportFailedError = () => {
toast.error(t("import.failed"))
}
const handleImportToStore = (gqlCollections: HoppCollection[]) => {
appendGraphqlCollections(gqlCollections)
toast.success(t("state.file_imported"))
const handleImportToStore = async (gqlCollections: HoppCollection[]) => {
// If user is logged in, try to import to backend first
if (currentUser.value) {
try {
const transformedCollection = gqlCollections.map((collection) =>
translateToPersonalCollectionFormat(collection)
)
const res = await importUserCollectionsFromJSON(
JSON.stringify(transformedCollection),
ReqType.Gql
)()
if (E.isRight(res)) {
// Backend import succeeded, now fetch and persist collections in store
const fetchResult = await fetchAndConvertUserCollections(ReqType.Gql)
if (E.isRight(fetchResult)) {
// Replace local collections with backend collections
setGraphqlCollections(fetchResult.right)
} else {
console.warn(
"Failed to fetch collections from backend after import:",
fetchResult.left
)
// Still append to local store as fallback
appendGraphqlCollections(gqlCollections)
}
toast.success(t("state.file_imported"))
return
}
// Backend import failed, fall back to local storage
console.warn(
"Backend import failed, falling back to local storage:",
res.left
)
appendGraphqlCollections(gqlCollections)
toast.success(t("state.file_imported"))
return
} catch (error) {
// Backend import failed, fall back to local storage
console.warn(
"Backend import failed, falling back to local storage:",
error
)
appendGraphqlCollections(gqlCollections)
toast.success(t("state.file_imported"))
return
}
} else {
// User not logged in, use local storage
appendGraphqlCollections(gqlCollections)
toast.success(t("state.file_imported"))
}
}
function translateToPersonalCollectionFormat(x: HoppCollection) {
const folders: HoppCollection[] = (x.folders ?? []).map(
translateToPersonalCollectionFormat
)
const data = {
auth: x.auth,
headers: x.headers,
variables: x.variables,
}
const obj = {
...x,
folders,
data,
}
if (x.id) obj.id = x.id
return obj
}
const emit = defineEmits<{

View file

@ -1083,12 +1083,7 @@ const createMockServer = (payload: {
}) => {
// Import the mock server store dynamically to avoid circular dependencies
import("~/newstore/mockServers").then(({ showCreateMockServerModal$ }) => {
// For personal collections, use the collection's _ref_id or id
// For child collections, we need to get the root collection ID
let collectionID =
payload.collection.id ||
payload.collection._ref_id ||
payload.collectionIndex
let collectionID = payload.collection.id ?? undefined
// If this is a child collection (folder), we need to get the root collection ID
if (payload.collectionIndex.includes("/")) {
@ -1096,7 +1091,7 @@ const createMockServer = (payload: {
const rootIndex = payload.collectionIndex.split("/")[0]
const rootCollection = myCollections.value[parseInt(rootIndex)]
if (rootCollection) {
collectionID = rootCollection.id || rootCollection._ref_id || rootIndex
collectionID = rootCollection.id ?? undefined
}
}

View file

@ -270,7 +270,7 @@ import { RESTTabService } from "~/services/tab/rest"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
import { WorkspaceService } from "~/services/workspace.service"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { handleTokenValidation } from "~/helpers/handleTokenValidation";
import { handleTokenValidation } from "~/helpers/handleTokenValidation"
const t = useI18n()
const interceptorService = useService(KernelInterceptorService)

View file

@ -52,6 +52,7 @@
/>
</HoppSmartTab>
<HoppSmartTab
v-if="ENABLE_EXPERIMENTAL_MOCK_SERVERS"
:id="'mock-servers'"
:icon="IconServer"
:label="`${t('tab.mock_servers')}`"
@ -75,10 +76,16 @@ import IconCode from "~icons/lucide/code"
import IconServer from "~icons/lucide/server"
import { ref } from "vue"
import { useI18n } from "@composables/i18n"
import { useSetting } from "@composables/settings"
import MockServerDashboard from "~/components/mockServer/MockServerDashboard.vue"
import { useMockServerWorkspaceSync } from "~/composables/mockServerWorkspace"
const t = useI18n()
const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting(
"ENABLE_EXPERIMENTAL_MOCK_SERVERS"
)
type RequestOptionTabs =
| "history"
| "collections"
@ -88,4 +95,7 @@ type RequestOptionTabs =
| "mock-servers"
const selectedNavigationTab = ref<RequestOptionTabs>("collections")
// Ensure mock servers are kept in sync with workspace changes globally
useMockServerWorkspaceSync()
</script>

View file

@ -102,15 +102,25 @@
</label>
<div class="flex items-center space-x-2">
<div
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight text-body font-mono"
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight"
>
{{ mockServerBaseUrl }}
{{
existingMockServer?.serverUrlPathBased ||
existingMockServer?.serverUrlDomainBased ||
""
}}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="copyToClipboard(mockServerBaseUrl)"
@click="
copyToClipboard(
existingMockServer?.serverUrlPathBased ||
existingMockServer?.serverUrlDomainBased ||
''
)
"
/>
</div>
</div>
@ -121,11 +131,11 @@
</label>
<div class="flex items-center space-x-2">
<span
class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="
existingMockServer?.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
? 'bg-green-600/20 text-green-200 border border-green-900/50'
: 'bg-gray-600/20 text-gray-200 border border-gray-900/50'
"
>
<span
@ -147,7 +157,7 @@
</div>
<!-- New Mock Server Form -->
<div v-else class="flex flex-col space-y-4">
<div v-else class="flex flex-col space-y-6">
<HoppSmartInput
v-model="mockServerName"
v-focus
@ -226,10 +236,14 @@
</div>
<!-- Help Text -->
<div class="p-4 bg-primaryLight rounded-md border border-dividerLight">
<p class="text-sm text-secondary">
<Icon-lucide-info class="inline w-4 h-4 mr-2 text-accent" />
{{ t("mock_server.description") }}
<div
class="py-4 px-3 bg-primaryLight rounded-md border border-dividerLight shadow-sm"
>
<p class="text-secondary flex space-x-2 items-start">
<Icon-lucide-info class="svg-icons text-accent" />
<span>
{{ t("mock_server.description") }}
</span>
</p>
</div>
</div>
@ -237,12 +251,6 @@
<template #footer>
<div class="flex justify-end space-x-2">
<HoppButtonSecondary
:label="t('action.cancel')"
outline
@click="closeModal"
/>
<!-- Start/Stop Server Button for existing mock server -->
<HoppButtonPrimary
v-if="isExistingMockServer"
@ -256,12 +264,6 @@
@click="toggleMockServer"
/>
<HoppButtonSecondary
v-if="isExistingMockServer"
:label="t('mock_server.view_logs')"
@click="showLogs = true"
/>
<!-- Create Mock Server Button for new mock server -->
<HoppButtonPrimary
v-else
@ -272,56 +274,48 @@
@click="createMockServer"
/>
<!-- Close button shown after server creation -->
<HoppButtonSecondary
v-if="showCloseButton"
:label="t('action.close')"
:label="t('action.cancel')"
outline
@click="closeModal"
/>
</div>
</template>
</HoppSmartModal>
<MockServerLogs
v-if="showLogs && existingMockServer"
:show="showLogs"
:mock-server-i-d="existingMockServer.id"
@close="showLogs = false"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream"
import {
showCreateMockServerModal$,
mockServers$,
addMockServer,
updateMockServer as updateMockServerInStore,
} from "~/newstore/mockServers"
import { restCollections$ } from "~/newstore/collections"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { TippyComponent } from "vue-tippy"
import { useToast } from "@composables/toast"
import { refAutoReset } from "@vueuse/core"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { computed, ref, watch } from "vue"
import { TippyComponent } from "vue-tippy"
import { MockServer, WorkspaceType } from "~/helpers/backend/graphql"
import {
createMockServer as createMockServerMutation,
updateMockServer,
} from "~/helpers/backend/mutations/MockServer"
import { MockServer, WorkspaceType } from "~/helpers/backend/graphql"
import { copyToClipboard as copyToClipboardHelper } from "~/helpers/utils/clipboard"
import { refAutoReset } from "@vueuse/core"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { restCollections$ } from "~/newstore/collections"
import {
addMockServer,
mockServers$,
showCreateMockServerModal$,
updateMockServer as updateMockServerInStore,
} from "~/newstore/mockServers"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { WorkspaceService } from "~/services/workspace.service"
// Icons
import IconServer from "~icons/lucide/server"
import IconPlay from "~icons/lucide/play"
import IconSquare from "~icons/lucide/square"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import MockServerLogs from "~/components/mockServer/MockServerLogs.vue"
import IconCopy from "~icons/lucide/copy"
import IconPlay from "~icons/lucide/play"
import IconServer from "~icons/lucide/server"
import IconSquare from "~icons/lucide/square"
const t = useI18n()
const toast = useToast()
@ -354,7 +348,6 @@ const showCloseButton = ref(false)
const createdServer = ref<MockServer | null>(null)
const delayInMsVal = ref<string>("0")
const isPublic = ref<boolean>(true)
const showLogs = ref(false)
const selectedCollectionID = ref("")
const selectedCollectionName = ref("")
const tippyActions = ref<TippyComponent | null>(null)
@ -362,16 +355,26 @@ const tippyActions = ref<TippyComponent | null>(null)
// Props computed from modal data
const show = computed(() => modalData.value.show)
const collectionID = computed(() => modalData.value.collectionID)
const collectionName = computed(
() => modalData.value.collectionName || "Unknown Collection"
)
const collectionName = computed(() => {
// Prefer name provided by modalData (pre-selected from caller)
if (modalData.value.collectionName) return modalData.value.collectionName
// Find existing mock server for this collection
// If user selected a collection inside the modal, use that
if (selectedCollectionName.value) return selectedCollectionName.value
// Try finding the collection from availableCollections using effectiveCollectionID
const id = effectiveCollectionID.value
if (!id) return "Unknown Collection"
const coll = availableCollections.value.find((c: any) => (c as any).id === id)
return (coll as any)?.name || (coll as any)?.title || "Unknown Collection"
})
// Find existing mock server for the effective collection (pre-selected or user-selected)
const existingMockServer = computed(() => {
if (!collectionID.value) return null
return mockServers.value.find(
(server) => server.collectionID === collectionID.value
)
const collId = effectiveCollectionID.value
if (!collId) return null
return mockServers.value.find((server) => server.collectionID === collId)
})
const isExistingMockServer = computed(() => !!existingMockServer.value)
@ -379,7 +382,7 @@ const isExistingMockServer = computed(() => !!existingMockServer.value)
// Collection options for the selector (only root collections)
const collectionOptions = computed(() => {
return availableCollections.value.map((collection) => {
const collectionId = collection.id || collection._ref_id
const collectionId = collection.id
const hasMockServer = mockServers.value.some(
(server) => server.collectionID === collectionId
)
@ -410,21 +413,6 @@ const selectCollection = (option: any) => {
selectedCollectionName.value = option.label
}
// Mock server base URL construction
const mockServerBaseUrl = computed(() => {
if (!existingMockServer.value) return ""
// Extract host and port from backend API URL
const backendApiUrl =
import.meta.env.VITE_BACKEND_API_URL || "http://localhost:3170"
const url = new URL(backendApiUrl)
const protocol = url.protocol
const port = url.port ? `:${url.port}` : ""
// Create subdomain URL: mock-1234.localhost:3170
return `${protocol}//${existingMockServer.value.subdomain}.${url.hostname}${port}`
})
// Copy functionality
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
@ -483,8 +471,10 @@ const createMockServer = async () => {
),
TE.match(
(error) => {
// `error` here is the message string produced by the mutation helper.
console.error("Failed to create mock server:", error)
toast.error(t("error.something_went_wrong"))
// Show the backend-provided error message if available, otherwise fallback to generic
toast.error(String(error) || t("error.something_went_wrong"))
loading.value = false
},
(result) => {

View file

@ -37,7 +37,7 @@
</label>
<div class="flex items-center space-x-2">
<div
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight text-body font-mono"
class="flex-1 px-3 py-2 border border-divider rounded bg-primaryLight"
>
{{
mockServer.serverUrlDomainBased || mockServer.serverUrlPathBased
@ -58,18 +58,28 @@
</div>
</div>
<!-- Status Toggle -->
<!-- Status Display (Read-only) -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.status") }}
{{ t("app.status") }}
</label>
<div class="flex items-center space-x-3">
<HoppSmartToggle :on="isActive" @change="isActive = !isActive" />
<span class="text-sm text-secondaryLight">
<div class="flex items-center space-x-2">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="
isActive
? 'bg-green-600/20 text-green-200 border border-green-900/50'
: 'bg-gray-600/20 text-gray-200 border border-gray-900/50'
"
>
<span
class="w-2 h-2 rounded-full mr-2"
:class="isActive ? 'bg-green-400' : 'bg-gray-400'"
></span>
{{
isActive
? t("mock_server.server_running")
: t("mock_server.server_stopped")
? t("mockServer.dashboard.active")
: t("mockServer.dashboard.inactive")
}}
</span>
</div>
@ -99,7 +109,7 @@
</label>
<div class="flex items-center space-x-3">
<HoppSmartToggle :on="isPublic" @change="isPublic = !isPublic" />
<span class="text-sm text-secondaryLight">
<span class="text-secondaryLight">
{{
isPublic
? t("mock_server.public_description")
@ -112,31 +122,52 @@
</template>
<template #footer>
<span class="flex space-x-2">
<div class="flex justify-end space-x-2">
<!-- Start/Stop Server Button (consistent with CreateMockServer) -->
<HoppButtonPrimary
:label="
isActive
? t('mock_server.stop_server')
: t('mock_server.start_server')
"
:loading="loading"
:icon="isActive ? IconSquare : IconPlay"
@click="toggleMockServer"
/>
<!-- Save button for other settings -->
<HoppButtonSecondary
outline
:label="t('action.save')"
:loading="loading"
@click="updateMockServer"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
@click="emit('hide-modal')"
/>
</span>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { ref, watch } from "vue"
import { useToast } from "~/composables/toast"
import { updateMockServer as updateMockServerMutation } from "~/helpers/backend/mutations/MockServer"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import type { MockServer } from "~/newstore/mockServers"
import { updateMockServer as updateMockServerInStore } from "~/newstore/mockServers"
// Icons
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import IconPlay from "~icons/lucide/play"
import IconSquare from "~icons/lucide/square"
interface Props {
show: boolean
@ -176,22 +207,74 @@ watch(
const updateMockServer = async () => {
loading.value = true
try {
// TODO: Implement mock server update API call
// const updatedMockServer = await updateMockServerAPI(props.mockServer.id, {
// name: mockServerName.value,
// isActive: isActive.value,
// delayInMs: delayInMs.value,
// isPublic: isPublic.value
// })
toast.success(t("mock_server.mock_server_updated"))
emit("hide-modal")
} catch (error) {
toast.error(t("error.something_went_wrong"))
} finally {
loading.value = false
// Prepare payload
const payload = {
name: mockServerName.value,
isActive: isActive.value,
delayInMs: delayInMs.value,
isPublic: isPublic.value,
}
await pipe(
updateMockServerMutation(props.mockServer.id, payload),
TE.match(
(error) => {
console.error("Failed to update mock server:", error)
toast.error(t("error.something_went_wrong"))
loading.value = false
},
() => {
// Update the mock server in the store with the changed fields
updateMockServerInStore(props.mockServer.id, payload)
toast.success(t("mock_server.mock_server_updated"))
emit("hide-modal")
// Update local state in case parent doesn't refresh immediately
mockServerName.value = payload.name
isActive.value = payload.isActive
delayInMs.value = payload.delayInMs || 0
isPublic.value = payload.isPublic
loading.value = false
}
)
)()
}
// Toggle mock server active state (consistent with CreateMockServer)
const toggleMockServer = async () => {
loading.value = true
const newActiveState = !isActive.value
await pipe(
updateMockServerMutation(props.mockServer.id, { isActive: newActiveState }),
TE.match(
(error) => {
console.error("Failed to update mock server:", error)
toast.error(t("error.something_went_wrong"))
loading.value = false
},
(result) => {
console.log("Mock server updated:", result)
toast.success(
newActiveState
? t("mock_server.mock_server_started")
: t("mock_server.mock_server_stopped")
)
// Update the mock server in the store
updateMockServerInStore(props.mockServer.id, {
isActive: newActiveState,
})
// Update local state
isActive.value = newActiveState
loading.value = false
}
)
)()
}
const copyToClipboardHandler = async (text: string) => {

View file

@ -0,0 +1,114 @@
<template>
<div class="flex flex-col space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<button
v-if="content && content.trim()"
class="flex items-center space-x-1 font-medium text-secondary hover:text-secondaryDark transition-colors"
@click="toggleExpanded"
>
<icon-lucide-chevron-right
:class="[
'h-4 w-4 transition-transform duration-200',
isExpanded ? 'rotate-90' : 'rotate-0',
]"
/>
<span>{{ title }}</span>
</button>
<span v-else class="font-medium text-secondary">{{ title }}</span>
</div>
</div>
<div v-if="content && content.trim() && isExpanded" class="relative group">
<div
class="absolute top-2 right-3 z-10 p-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copySuccess ? IconCheck : IconCopy"
class="p-1 rounded transition-colors"
@click="copyContent"
/>
</div>
<pre
class="relative whitespace-pre-wrap cursor-text select-text break-words bg-primaryLight border border-dividerLight rounded-sm p-4 max-h-96 overflow-y-auto"
:class="[isValidJSON ? 'text-accent' : 'text-secondaryLight']"
>{{ formattedContent }}</pre
>
</div>
<div
v-else-if="!content || !content.trim()"
class="text-xs text-secondaryLight italic py-2"
>
{{ t("state.no_content_found") }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import { useI18n } from "@composables/i18n"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { refAutoReset } from "@vueuse/core"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import { HoppSmartItem } from "@hoppscotch/ui"
interface Props {
title: string
content: string | null | undefined
defaultExpanded?: boolean
}
const props = withDefaults(defineProps<Props>(), {
defaultExpanded: false,
})
const t = useI18n()
const isExpanded = ref(props.defaultExpanded)
const copySuccess = refAutoReset(false, 1000)
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
}
const isValidJSON = computed(() => {
if (!props.content) return false
try {
JSON.parse(props.content)
return true
} catch {
return false
}
})
const formattedContent = computed(() => {
if (!props.content) return ""
if (isValidJSON.value) {
try {
const parsed = JSON.parse(props.content)
return JSON.stringify(parsed, null, 2)
} catch {
return props.content
}
}
return props.content
})
const copyContent = () => {
if (!props.content) return
try {
copyToClipboard(formattedContent.value)
copySuccess.value = true
} catch (error) {
console.error("Failed to copy content:", error)
}
}
</script>

View file

@ -58,13 +58,16 @@
@click="openCreateModal"
/>
</div>
<div v-else class="divide-y divide-dividerLight">
<div v-else class="flex flex-1 flex-col space-y-2 py-2">
<div
v-for="mockServer in mockServers"
:key="mockServer.id"
class="group flex items-stretch"
>
<span class="flex cursor-pointer items-center justify-center px-4">
<span
class="flex cursor-pointer items-center justify-center px-4"
@click="openMockServerLogs(mockServer)"
>
<component
:is="IconServer"
class="svg-icons"
@ -75,7 +78,7 @@
/>
</span>
<span
class="flex min-w-0 flex-1 cursor-pointer py-2 pr-2 transition group-hover:text-secondaryDark"
class="flex min-w-0 flex-1 cursor-pointer pr-2 transition group-hover:text-secondaryDark"
@click="openMockServerLogs(mockServer)"
>
<div class="flex min-w-0 flex-1 flex-col">
@ -201,53 +204,48 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { computed, ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { useMockServerStatus } from "~/composables/mockServer"
import { useToast } from "~/composables/toast"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { platform } from "~/platform"
import type { MockServer } from "~/newstore/mockServers"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { platform } from "~/platform"
import {
loadMockServers,
deleteMockServer as deleteMockServerInStore,
showCreateMockServerModal$,
updateMockServer as updateMockServerInStore,
deleteMockServer as deleteMockServerInStore,
} from "~/newstore/mockServers"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
import {
updateMockServer as updateMockServerMutation,
deleteMockServer as deleteMockServerMutation,
} from "~/helpers/backend/mutations/MockServer"
import MockServerCreateMockServer from "~/components/mockServer/CreateMockServer.vue"
import MockServerEditMockServer from "~/components/mockServer/EditMockServer.vue"
import MockServerLogs from "~/components/mockServer/MockServerLogs.vue"
import {
deleteMockServer as deleteMockServerMutation,
updateMockServer as updateMockServerMutation,
} from "~/helpers/backend/mutations/MockServer"
// Icons
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconServer from "~icons/lucide/server"
import IconEdit from "~icons/lucide/edit"
import IconTrash2 from "~icons/lucide/trash-2"
import IconPlay from "~icons/lucide/play"
import IconStop from "~icons/lucide/stop-circle"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import IconEdit from "~icons/lucide/edit"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconPlay from "~icons/lucide/play"
import IconPlus from "~icons/lucide/plus"
import IconServer from "~icons/lucide/server"
import IconStop from "~icons/lucide/stop-circle"
import IconTrash2 from "~icons/lucide/trash-2"
const t = useI18n()
const toast = useToast()
const colorMode = useColorMode()
const { mockServers } = useMockServerStatus()
const workspaceService = useService(WorkspaceService)
const loading = ref(false)
const showCreateModal = ref(false)
const showEditModal = ref(false)
@ -376,28 +374,4 @@ const openCreateModal = () => {
collectionName: undefined,
})
}
// Load mock servers on component mount
// Load mock servers for current workspace
const loadCurrentWorkspaceMockServers = async () => {
if (!platform.auth.getCurrentUser()) return
loading.value = true
try {
await loadMockServers()
} catch (error) {
console.error("Failed to load mock servers:", error)
} finally {
loading.value = false
}
}
onMounted(loadCurrentWorkspaceMockServers)
// Watch for workspace changes and reload mock servers
watch(
() => workspaceService.currentWorkspace.value,
loadCurrentWorkspaceMockServers,
{ deep: true }
)
</script>

View file

@ -3,6 +3,7 @@
v-if="show"
dialog
:title="t('mock_server.logs_title')"
styles="sm:max-w-4xl"
@close="close"
>
<template #body>
@ -19,60 +20,78 @@
<div
v-for="log in logs"
:key="log.id"
class="mb-4 p-3 border rounded"
class="mb-4 border border-dividerDark rounded overflow-hidden"
>
<div class="flex justify-between items-start">
<div class="font-mono text-sm text-secondaryDark">
{{ log.requestMethod }} {{ log.requestPath }}
</div>
<div class="text-sm text-secondaryLight">
{{ new Date(log.executedAt).toLocaleString() }}
<div
class="p-3 cursor-pointer hover:bg-primaryLight/5 transition-colors duration-200"
@click="toggleLogExpansion(log.id)"
>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-3">
<icon-lucide-chevron-right
class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-90': isLogExpanded(log.id) }"
/>
<div
:style="{
color: getMethodLabelColor(log.requestMethod),
}"
class="flex-1"
>
{{ log.requestMethod }}
</div>
<div class="text-secondaryDark truncate">
{{ log.requestPath }}
</div>
<div
v-if="log.responseStatus"
class="px-2 py-1 rounded text-xs font-medium"
:class="getStatusColor(log.responseStatus)"
>
{{ log.responseStatus }}
</div>
</div>
<div class="text-secondaryLight flex flex-1 justify-center">
{{ formatExecutedAt(log.executedAt) }}
</div>
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
:icon="IconTrash"
class="bg-transparent hover:bg-transparent !text-red-500"
@click.stop="confirmRemoveLog(log.id)"
/>
</div>
</div>
<div class="mt-2 text-xs text-secondaryLight">
<div>
<span class="font-medium"
>{{ t("mock_server.request_headers") }}:</span
>
<pre class="whitespace-pre-wrap">{{
prettyJSON(log.requestHeaders)
}}</pre>
<div
v-if="isLogExpanded(log.id)"
class="border-t border-dividerDark"
>
<div class="py-4 px-3 text-xs flex flex-col space-y-4">
<MockServerLogSection
:title="t('mock_server.request_headers')"
:content="log.requestHeaders"
/>
<MockServerLogSection
v-if="log.requestBody"
:title="t('mock_server.request_body')"
:content="log.requestBody"
/>
<MockServerLogSection
:title="t('mock_server.response_headers')"
:content="log.responseHeaders"
/>
<MockServerLogSection
v-if="log.responseBody"
:title="t('mock_server.response_body')"
:content="log.responseBody"
/>
</div>
<div v-if="log.requestBody">
<span class="font-medium"
>{{ t("mock_server.request_body") }}:</span
>
<pre class="whitespace-pre-wrap">{{
prettyJSON(log.requestBody)
}}</pre>
</div>
<div class="mt-2">
<span class="font-medium"
>{{ t("mock_server.response_status") }}:</span
>
{{ log.responseStatus }}
</div>
<div class="mt-1">
<span class="font-medium"
>{{ t("mock_server.response_headers") }}:</span
>
<pre class="whitespace-pre-wrap">{{
prettyJSON(log.responseHeaders)
}}</pre>
</div>
<div v-if="log.responseBody" class="mt-1">
<span class="font-medium"
>{{ t("mock_server.response_body") }}:</span
>
<pre class="whitespace-pre-wrap">{{
prettyJSON(log.responseBody)
}}</pre>
</div>
</div>
<div class="flex justify-end mt-2">
<HoppButtonSecondary outline @click="removeLog(log.id)">{{
t("action.delete")
}}</HoppButtonSecondary>
</div>
</div>
</div>
@ -80,12 +99,17 @@
</template>
<template #footer>
<div class="flex justify-end">
<HoppButtonSecondary @click="close">{{
t("action.close")
}}</HoppButtonSecondary>
<HoppButtonPrimary :label="t('action.close')" @click="close" />
</div>
</template>
</HoppSmartModal>
<HoppSmartConfirmModal
:show="showDeleteConfirm"
:title="t('mock_server.confirm_delete_log')"
@hide-modal="showDeleteConfirm = false"
@resolve="confirmDelete"
/>
</template>
<script setup lang="ts">
@ -98,6 +122,9 @@ import {
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { useToast } from "~/composables/toast"
import IconTrash from "~icons/lucide/trash"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
import { HoppSmartItem } from "@hoppscotch/ui"
const props = defineProps<{ show: boolean; mockServerID: string }>()
const emit = defineEmits<{ (e: "close"): void }>()
@ -107,6 +134,9 @@ const toast = useToast()
const loading = ref(false)
const logs = ref<any[]>([])
const expandedLogs = ref<Set<string>>(new Set())
const showDeleteConfirm = ref(false)
const logToDelete = ref<string | null>(null)
const fetchLogs = async () => {
loading.value = true
@ -132,31 +162,54 @@ onMounted(() => {
const close = () => emit("close")
const removeLog = async (id: string) => {
await pipe(
deleteMockServerLog(id),
TE.match(
(err) => {
console.error("Failed to delete log", err)
toast.error(t("error.something_went_wrong"))
},
(res) => {
if (res) {
logs.value = logs.value.filter((l) => l.id !== id)
toast.success(t("mock_server.log_deleted"))
}
}
)
)()
const confirmRemoveLog = (id: string) => {
logToDelete.value = id
showDeleteConfirm.value = true
}
const prettyJSON = (s: string | null | undefined) => {
try {
if (!s) return ""
const obj = typeof s === "string" ? JSON.parse(s) : s
return JSON.stringify(obj, null, 2)
} catch (e) {
return String(s)
const confirmDelete = async () => {
if (logToDelete.value) {
await pipe(
deleteMockServerLog(logToDelete.value),
TE.match(
(err) => {
console.error("Failed to delete log", err)
toast.error(t("error.something_went_wrong"))
},
(res) => {
if (res) {
logs.value = logs.value.filter((l) => l.id !== logToDelete.value)
toast.success(t("mock_server.log_deleted"))
logToDelete.value = null
showDeleteConfirm.value = false
}
}
)
)()
}
}
const formatExecutedAt = (executedAt: string) => {
return new Date(executedAt).toLocaleString()
}
const toggleLogExpansion = (id: string) => {
if (expandedLogs.value.has(id)) {
expandedLogs.value.delete(id)
} else {
expandedLogs.value.add(id)
}
}
const isLogExpanded = (id: string) => {
return expandedLogs.value.has(id)
}
const getStatusColor = (statusCode: number) => {
const status = statusCode.toString()
if (status.startsWith("2")) return "bg-green-800/20 text-green-600"
if (status.startsWith("4")) return "bg-yellow-800/20 text-yellow-400"
if (status.startsWith("5")) return "bg-red-800/20 text-red-400"
return "bg-gray-600/20 text-secondaryDark"
}
</script>

View file

@ -0,0 +1,51 @@
import { onMounted, watch } from "vue"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
import { setMockServers, loadMockServers } from "~/newstore/mockServers"
import { platform } from "~/platform"
import { useSetting } from "./settings"
/**
* Composable to handle mock server state when workspace changes
* This ensures mock servers are cleared immediately when switching workspaces
* to prevent showing stale data from the previous workspace
*/
export function useMockServerWorkspaceSync() {
const workspaceService = useService(WorkspaceService)
const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting(
"ENABLE_EXPERIMENTAL_MOCK_SERVERS"
)
const isAuthenticated = !!platform.auth.getCurrentUser()
// Initial load of mock servers for the current workspace
onMounted(() => {
if (!isAuthenticated || !ENABLE_EXPERIMENTAL_MOCK_SERVERS.value) return
loadMockServers().catch(() => setMockServers([]))
})
// Watch for workspace changes and clear mock servers immediately
watch(
() => workspaceService.currentWorkspace.value,
(newWorkspace, oldWorkspace) => {
if (!isAuthenticated || !ENABLE_EXPERIMENTAL_MOCK_SERVERS.value) return
// Clear mock servers when workspace changes to prevent stale data
if (
newWorkspace?.type !== oldWorkspace?.type ||
(newWorkspace?.type === "team" &&
oldWorkspace?.type === "team" &&
newWorkspace.teamID !== oldWorkspace.teamID)
) {
// Clear mock servers immediately to prevent showing stale data
setMockServers([])
// If user is authenticated, reload mock servers for the new workspace
if (platform.auth.getCurrentUser()) {
// fire-and-forget; loadMockServers handles errors internally
loadMockServers().catch(() => setMockServers([]))
}
}
},
{ deep: true, immediate: false }
)
}

View file

@ -0,0 +1,11 @@
mutation ImportUserCollectionsFromJSON(
$jsonString: String!
$reqType: ReqType!
$parentCollectionID: ID
) {
importUserCollectionsFromJSON(
jsonString: $jsonString
reqType: $reqType
parentCollectionID: $parentCollectionID
)
}

View file

@ -0,0 +1,34 @@
query GetGQLRootUserCollections($cursor: ID, $take: Int) {
rootGQLUserCollections(cursor: $cursor, take: $take) {
id
title
data
type
parent {
id
}
requests {
id
title
request
type
collectionID
}
childrenGQL {
id
title
data
type
parent {
id
}
requests {
id
title
request
type
collectionID
}
}
}
}

View file

@ -0,0 +1,34 @@
query GetUserRootCollections($cursor: ID, $take: Int) {
rootRESTUserCollections(cursor: $cursor, take: $take) {
id
title
data
type
parent {
id
}
requests {
id
title
request
type
collectionID
}
childrenREST {
id
title
data
type
parent {
id
}
requests {
id
title
request
type
collectionID
}
}
}
}

View file

@ -1,5 +1,7 @@
import * as TE from "fp-ts/TaskEither"
import { client } from "../GQLClient"
import { GQLError } from "../GQLClient"
import { getI18n } from "~/modules/i18n"
import {
CreateMockServerDocument,
UpdateMockServerDocument,
@ -36,6 +38,7 @@ export type MockServer = {
}
type CreateMockServerError =
| "mock_server/invalid_collection"
| "mock_server/invalid_collection_id"
| "mock_server/name_too_short"
| "mock_server/limit_exceeded"
@ -73,7 +76,26 @@ export const createMockServer = (
.toPromise()
if (result.error) {
throw new Error(result.error.message || "Failed to create mock server")
// Try to extract a useful error message from the GraphQL error
const err: any = result.error
let message = err.message
// urql exposes GraphQL errors in graphQLErrors array
const gqlErr = (err.graphQLErrors && err.graphQLErrors[0]) || null
if (gqlErr) {
// Prefer originalError.message from backend if present (it may be an array of messages)
const orig =
gqlErr.extensions &&
gqlErr.extensions.originalError &&
gqlErr.extensions.originalError.message
if (orig) {
message = Array.isArray(orig) ? orig.join(", ") : String(orig)
} else if (gqlErr.message) {
message = gqlErr.message
}
}
throw new Error(message)
}
if (!result.data) {
@ -183,3 +205,36 @@ export const getTeamMockServers = (
},
(error) => (error as Error).message as CreateMockServerError
)
// Centralized mapper for backend GraphQL error tokens to user-facing messages.
export const getErrorMessage = (err: GQLError<string> | string | Error) => {
const t = getI18n()
// Normalize to GQLError-like shape
let gErr: GQLError<string> | null = null
if (typeof err === "string") {
gErr = { type: "gql_error", error: err }
} else if (err instanceof Error) {
gErr = { type: "network_error", error: err }
} else if ((err as any)?.type) {
gErr = err as GQLError<string>
}
if (!gErr) return t("error.something_went_wrong")
if (gErr.type === "network_error") {
console.error(gErr.error)
return t("error.network_error")
}
const code = String(gErr.error)
switch (code) {
case "mock_server/invalid_collection":
case "mock_server/invalid_collection_id":
return t("mock_server.invalid_collection_error")
default:
return t("error.something_went_wrong")
}
}

View file

@ -0,0 +1,203 @@
import { runMutation } from "../GQLClient"
import { runGQLQuery } from "../GQLClient"
import {
GetGqlRootUserCollectionsDocument,
GetGqlRootUserCollectionsQuery,
GetGqlRootUserCollectionsQueryVariables,
GetUserRootCollectionsDocument,
GetUserRootCollectionsQuery,
GetUserRootCollectionsQueryVariables,
ImportUserCollectionsFromJsonDocument,
ImportUserCollectionsFromJsonMutation,
ImportUserCollectionsFromJsonMutationVariables,
ReqType,
UserCollection,
UserRequest,
} from "../graphql"
import {
HoppCollection,
makeCollection,
HoppRESTRequest,
HoppGQLRequest,
getDefaultRESTRequest,
getDefaultGQLRequest,
} from "@hoppscotch/data"
import * as E from "fp-ts/Either"
export const importUserCollectionsFromJSON = (
collectionJSON: string,
reqType: ReqType,
parentCollectionID?: string
) =>
runMutation<
ImportUserCollectionsFromJsonMutation,
ImportUserCollectionsFromJsonMutationVariables,
""
>(ImportUserCollectionsFromJsonDocument, {
jsonString: collectionJSON,
reqType,
parentCollectionID,
})
// Use generated GraphQL documents instead of inline gql tags
export const getUserRootCollections = () =>
runGQLQuery<
GetUserRootCollectionsQuery,
GetUserRootCollectionsQueryVariables,
""
>({
query: GetUserRootCollectionsDocument,
variables: {},
})
export const getGQLRootUserCollections = () =>
runGQLQuery<
GetGqlRootUserCollectionsQuery,
GetGqlRootUserCollectionsQueryVariables,
""
>({
query: GetGqlRootUserCollectionsDocument,
variables: {},
})
/**
* Converts a UserRequest from backend format to HoppRequest format
*/
function convertUserRequestToHoppRequest(
userRequest: UserRequest
): HoppRESTRequest | HoppGQLRequest {
try {
const parsedRequest = JSON.parse(userRequest.request)
// Add the backend ID and title to the request
const request = {
...parsedRequest,
id: userRequest.id,
name: userRequest.title,
}
return request
} catch (error) {
console.warn("Failed to parse user request data:", error)
// Return a default request if parsing fails
if (userRequest.type === ReqType.Rest) {
const defaultRequest = getDefaultRESTRequest()
defaultRequest.id = userRequest.id
defaultRequest.name = userRequest.title
return defaultRequest
}
const defaultRequest = getDefaultGQLRequest()
defaultRequest.id = userRequest.id
defaultRequest.name = userRequest.title
return defaultRequest
}
}
/**
* Parse collection data similar to the existing parseCollectionData function in helpers.ts
*/
function parseUserCollectionData(data: string | null | undefined) {
const defaultDataProps = {
auth: { authType: "inherit", authActive: true },
headers: [],
variables: [],
}
if (!data) {
return defaultDataProps
}
try {
const parsedData = JSON.parse(data)
return {
auth: parsedData?.auth || defaultDataProps.auth,
headers: parsedData?.headers || defaultDataProps.headers,
variables: parsedData?.variables || defaultDataProps.variables,
}
} catch (error) {
console.warn("Failed to parse user collection data:", error)
return defaultDataProps
}
}
/**
* Converts a UserCollection from backend format to HoppCollection format
* Following the same pattern as teamCollectionJSONToHoppRESTColl in helpers.ts
*/
export function convertUserCollectionToHoppCollection(
userCollection: UserCollection,
reqType: ReqType
): HoppCollection {
const { auth, headers, variables } = parseUserCollectionData(
userCollection.data
)
// Get the appropriate children based on request type
const children =
reqType === ReqType.Rest
? userCollection.childrenREST
: userCollection.childrenGQL
// Convert requests - filter by type and convert
const requests = userCollection.requests
? userCollection.requests
.filter((req) => req.type === reqType)
.map(convertUserRequestToHoppRequest)
: []
const collection = makeCollection({
name: userCollection.title,
folders: children
? children.map((child) =>
convertUserCollectionToHoppCollection(child, reqType)
)
: [],
requests: requests,
auth,
headers,
variables,
})
// Add the backend ID to the collection
collection.id = userCollection.id
return collection
}
/**
* Fetches user collections from backend and converts them to HoppCollection format
*/
export const fetchAndConvertUserCollections = async (reqType: ReqType) => {
const fetchFunction =
reqType === ReqType.Rest
? getUserRootCollections
: getGQLRootUserCollections
const result = await fetchFunction()
if (E.isLeft(result)) {
return E.left(result.left)
}
if (reqType === ReqType.Rest) {
const right = result.right as GetUserRootCollectionsQuery
const collections = right.rootRESTUserCollections
const convertedCollections = collections.map((collection) =>
convertUserCollectionToHoppCollection(
collection as unknown as UserCollection,
reqType
)
)
return E.right(convertedCollections)
}
const right = result.right as GetGqlRootUserCollectionsQuery
const collections = right.rootGQLUserCollections
const convertedCollections = collections.map((collection) =>
convertUserCollectionToHoppCollection(
collection as unknown as UserCollection,
reqType
)
)
return E.right(convertedCollections)
}

View file

@ -1,14 +1,14 @@
import { pluck } from "rxjs/operators"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { BehaviorSubject } from "rxjs"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { pluck } from "rxjs/operators"
import {
getMyMockServers,
getTeamMockServers,
} from "~/helpers/backend/queries/MockServer"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { getService } from "~/modules/dioc"
import { WorkspaceService } from "~/services/workspace.service"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
export type WorkspaceType = "USER" | "TEAM"
@ -166,6 +166,8 @@ export function loadMockServers(skip?: number, take?: number) {
TE.match(
(error) => {
console.error("Failed to load mock servers:", error)
// Clear mock servers on error to prevent stale data
setMockServers([])
},
(mockServers) => {
setMockServers(mockServers)
@ -179,6 +181,8 @@ export function loadMockServers(skip?: number, take?: number) {
TE.match(
(error) => {
console.error("Failed to load mock servers:", error)
// Clear mock servers on error to prevent stale data
setMockServers([])
},
(mockServers) => {
setMockServers(mockServers)
@ -199,6 +203,8 @@ export function loadTeamMockServers(
TE.match(
(error) => {
console.error("Failed to load team mock servers:", error)
// Clear mock servers on error to prevent stale data
setMockServers([])
},
(mockServers) => {
setMockServers(mockServers)
@ -214,6 +220,9 @@ export function loadMockServersForWorkspace(
skip?: number,
take?: number
) {
// Clear existing mock servers first to prevent stale data
setMockServers([])
if (workspaceType === "team" && teamID) {
return loadTeamMockServers(teamID, skip, take)
}

View file

@ -84,6 +84,7 @@ export type SettingsDef = {
CUSTOM_NAMING_STYLE: string
EXPERIMENTAL_SCRIPTING_SANDBOX: boolean
ENABLE_EXPERIMENTAL_MOCK_SERVERS: boolean
}
let defaultProxyURL = DEFAULT_HOPP_PROXY_URL
@ -146,6 +147,7 @@ export const getDefaultSettings = (): SettingsDef => {
CUSTOM_NAMING_STYLE: "",
EXPERIMENTAL_SCRIPTING_SANDBOX: true,
ENABLE_EXPERIMENTAL_MOCK_SERVERS: true,
}
}

View file

@ -156,7 +156,7 @@
</div>
</div>
</div>
<div class="flex items-center">
<div class="flex items-center py-4">
<HoppSmartToggle
:on="EXPERIMENTAL_SCRIPTING_SANDBOX"
@change="toggleSetting('EXPERIMENTAL_SCRIPTING_SANDBOX')"
@ -164,6 +164,14 @@
{{ t("settings.experimental_scripting_sandbox") }}
</HoppSmartToggle>
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="ENABLE_EXPERIMENTAL_MOCK_SERVERS"
@change="toggleSetting('ENABLE_EXPERIMENTAL_MOCK_SERVERS')"
>
{{ t("settings.enable_experimental_mock_servers") }}
</HoppSmartToggle>
</div>
</section>
</div>
</div>
@ -354,6 +362,9 @@ const CUSTOM_NAMING_STYLE = useSetting("CUSTOM_NAMING_STYLE")
const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting(
"EXPERIMENTAL_SCRIPTING_SANDBOX"
)
const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting(
"ENABLE_EXPERIMENTAL_MOCK_SERVERS"
)
const supportedNamingStyles = [
{

View file

@ -85,6 +85,7 @@ const SettingsDefSchema = z.object({
CUSTOM_NAMING_STYLE: z.string().optional().catch(""),
EXPERIMENTAL_SCRIPTING_SANDBOX: z.optional(z.boolean()),
ENABLE_EXPERIMENTAL_MOCK_SERVERS: z.optional(z.boolean()),
})
const HoppRESTRequestSchema = entityReference(HoppRESTRequest)

View file

@ -51,7 +51,6 @@ import {
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "@hoppscotch/common/newstore/collections"
import { loadMockServers } from "@hoppscotch/common/newstore/mockServers"
import {
GQLHeader,
HoppCollection,
@ -84,7 +83,6 @@ function initCollectionsSync() {
if (user) {
loadUserCollections("REST")
loadUserCollections("GQL")
loadMockServers()
}
})

View file

@ -1,6 +1,6 @@
import { CollectionsPlatformDef } from "@hoppscotch/common/platform/collections"
import { authEvents$, def as platformAuth } from "@platform/auth/desktop"
import { runDispatchWithOutSyncing } from "@lib/sync"
import { authEvents$, def as platformAuth } from "@platform/auth/desktop"
import {
exportUserCollectionsToJSON,
@ -19,6 +19,11 @@ import {
} from "./api"
import { collectionsSyncer, getStoreByCollectionType } from "./sync"
import {
ReqType,
UserCollectionDuplicatedData,
UserRequest,
} from "@api/generated/graphql"
import { runGQLSubscription } from "@hoppscotch/common/helpers/backend/GQLClient"
import {
addGraphqlCollection,
@ -51,7 +56,6 @@ import {
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "@hoppscotch/common/newstore/collections"
import { loadMockServers } from "@hoppscotch/common/newstore/mockServers"
import {
generateUniqueRefId,
GQLHeader,
@ -62,11 +66,6 @@ import {
HoppRESTRequest,
} from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import {
ReqType,
UserCollectionDuplicatedData,
UserRequest,
} from "@api/generated/graphql"
import { gqlCollectionsSyncer } from "./gqlCollections.sync"
function initCollectionsSync() {
@ -85,7 +84,6 @@ function initCollectionsSync() {
if (user) {
loadUserCollections("REST")
loadUserCollections("GQL")
loadMockServers()
}
})

View file

@ -1,6 +1,6 @@
import { CollectionsPlatformDef } from "@hoppscotch/common/platform/collections"
import { authEvents$, def as platformAuth } from "@platform/auth/web"
import { runDispatchWithOutSyncing } from "@lib/sync"
import { authEvents$, def as platformAuth } from "@platform/auth/web"
import {
exportUserCollectionsToJSON,
@ -19,6 +19,11 @@ import {
} from "./api"
import { collectionsSyncer, getStoreByCollectionType } from "./sync"
import {
ReqType,
UserCollectionDuplicatedData,
UserRequest,
} from "@api/generated/graphql"
import { runGQLSubscription } from "@hoppscotch/common/helpers/backend/GQLClient"
import {
addGraphqlCollection,
@ -51,7 +56,6 @@ import {
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "@hoppscotch/common/newstore/collections"
import { loadMockServers } from "@hoppscotch/common/newstore/mockServers"
import {
generateUniqueRefId,
GQLHeader,
@ -62,11 +66,6 @@ import {
HoppRESTRequest,
} from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import {
ReqType,
UserCollectionDuplicatedData,
UserRequest,
} from "@api/generated/graphql"
import { gqlCollectionsSyncer } from "./gqlCollections.sync"
function initCollectionsSync() {
@ -85,7 +84,6 @@ function initCollectionsSync() {
if (user) {
loadUserCollections("REST")
loadUserCollections("GQL")
loadMockServers()
}
})

View file

@ -136,9 +136,8 @@
"title": "Mock Server",
"description": "Configure mock server settings used to host example responses.",
"wildcard_domain": "Wildcard Domain",
"wildcard_domain_placeholder": "e.g. *.example.com",
"secure_cookies": "Allow secure cookies",
"secure_cookies_desc": "Use secure cookies for responses from the mock server"
"wildcard_domain_description": "This field requires a full wildcard domain format. The input must start with an asterisk (*) followed by a dot (.) and then the domain name.",
"wildcard_domain_example": "Example: *.mock.domain.com"
},
"update_failure": "Failed to update server configurations"
},

View file

@ -17,29 +17,19 @@
<label class="block text-sm font-medium text-secondaryDark mb-1">
{{ t('configs.mock_server.wildcard_domain') }}
</label>
<input
<HoppSmartInput
v-model="mockFields.mock_server_wildcard_domain"
type="text"
class="w-full rounded border p-2"
:placeholder="t('configs.mock_server.wildcard_domain_placeholder')"
/>
</div>
<div class="flex items-center justify-between">
<div>
<h5 class="font-medium">
{{ t('configs.mock_server.secure_cookies') }}
</h5>
<p class="text-secondaryLight text-sm">
{{ t('configs.mock_server.secure_cookies_desc') }}
</p>
</div>
<HoppSmartToggle
:on="mockFields.allow_secure_cookies"
@change="
mockFields.allow_secure_cookies = !mockFields.allow_secure_cookies
"
:placeholder="'e.g. *.mock.example.com'"
class="!bg-primaryLight border border-divider rounded"
input-styles="!border-0"
/>
<p class="text-secondaryLight text-sm mt-2">
{{ t('configs.mock_server.wildcard_domain_description') }}
</p>
<p class="text-secondaryLight text-sm mt-1 font-mono">
{{ t('configs.mock_server.wildcard_domain_example') }}
</p>
</div>
</div>
</div>
@ -69,7 +59,6 @@ const mockFields = computed({
return (
workingConfigs.value.mockServerConfigs?.fields ?? {
mock_server_wildcard_domain: '',
allow_secure_cookies: false,
}
);
},

View file

@ -191,8 +191,6 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
mock_server_wildcard_domain: getFieldValue(
InfraConfigEnum.MockServerWildcardDomain
),
allow_secure_cookies:
getFieldValue(InfraConfigEnum.AllowSecureCookies) === 'true',
},
},
};

View file

@ -92,7 +92,6 @@ export type ServerConfigs = {
name: string;
fields: {
mock_server_wildcard_domain: string;
allow_secure_cookies: boolean;
};
};
};
@ -289,10 +288,6 @@ export const MOCK_SERVER_CONFIGS: Config[] = [
name: InfraConfigEnum.MockServerWildcardDomain,
key: 'mock_server_wildcard_domain',
},
{
name: InfraConfigEnum.AllowSecureCookies,
key: 'allow_secure_cookies',
},
];
export const ALL_CONFIGS = [