feat: mock server (#5482)

Co-authored-by: Anwarul Islam <anwaarulislaam@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Mir Arif Hasan 2025-10-27 23:33:22 +06:00 committed by GitHub
parent dd8744f292
commit 3acc0ec9b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 6249 additions and 69 deletions

View file

@ -16,5 +16,16 @@
}
:3170 {
reverse_proxy localhost:8080
@mock {
header_regexp host Host ^[^.]+\.mock\..*$
}
handle @mock {
rewrite * /mock{uri}
reverse_proxy localhost:8080
}
handle {
reverse_proxy localhost:8080
}
}

View file

@ -18,7 +18,18 @@
# Handle requests under `/backend*` path
handle_path /backend* {
reverse_proxy localhost:8080
@mock {
header_regexp host Host ^[^.]+\.mock\..*$
}
handle @mock {
rewrite * /mock{uri}
reverse_proxy localhost:8080
}
handle {
reverse_proxy localhost:8080
}
}
# Handle requests under `/desktop-app-server*` path

View file

@ -4,5 +4,16 @@
}
:80 :3170 {
reverse_proxy localhost:8080
@mock {
header_regexp host Host ^[^.]+\.mock\..*$
}
handle @mock {
rewrite * /mock{uri}
reverse_proxy localhost:8080
}
handle {
reverse_proxy localhost:8080
}
}

View file

@ -0,0 +1,142 @@
-- CreateEnum
CREATE TYPE "WorkspaceType" AS ENUM ('USER', 'TEAM');
-- CreateEnum
CREATE TYPE "MockServerAction" AS ENUM ('CREATED', 'DELETED', 'ACTIVATED', 'DEACTIVATED');
-- CreateTable
CREATE TABLE "MockServer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"subdomain" TEXT NOT NULL,
"creatorUid" TEXT,
"collectionID" TEXT NOT NULL,
"workspaceType" "WorkspaceType" NOT NULL,
"workspaceID" TEXT NOT NULL,
"delayInMs" INTEGER NOT NULL DEFAULT 0,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"hitCount" INTEGER NOT NULL DEFAULT 0,
"lastHitAt" TIMESTAMPTZ(3),
"createdOn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedOn" TIMESTAMPTZ(3) NOT NULL,
"deletedAt" TIMESTAMPTZ(3),
CONSTRAINT "MockServer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MockServerLog" (
"id" TEXT NOT NULL,
"mockServerID" TEXT NOT NULL,
"requestMethod" TEXT NOT NULL,
"requestPath" TEXT NOT NULL,
"requestHeaders" JSONB NOT NULL,
"requestBody" JSONB,
"requestQuery" JSONB,
"responseStatus" INTEGER NOT NULL,
"responseHeaders" JSONB NOT NULL,
"responseBody" JSONB,
"responseTime" INTEGER NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"executedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MockServerLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MockServerActivity" (
"id" TEXT NOT NULL,
"mockServerID" TEXT NOT NULL,
"action" "MockServerAction" NOT NULL,
"performedBy" TEXT,
"performedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MockServerActivity_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "MockServer_subdomain_key" ON "MockServer"("subdomain");
-- CreateIndex
CREATE INDEX "MockServerLog_mockServerID_idx" ON "MockServerLog"("mockServerID");
-- CreateIndex
CREATE INDEX "MockServerLog_mockServerID_executedAt_idx" ON "MockServerLog"("mockServerID", "executedAt");
-- CreateIndex
CREATE INDEX "MockServerActivity_mockServerID_idx" ON "MockServerActivity"("mockServerID");
-- AddForeignKey
ALTER TABLE "MockServer" ADD CONSTRAINT "MockServer_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MockServerLog" ADD CONSTRAINT "MockServerLog_mockServerID_fkey" FOREIGN KEY ("mockServerID") REFERENCES "MockServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MockServerActivity" ADD CONSTRAINT "MockServerActivity_mockServerID_fkey" FOREIGN KEY ("mockServerID") REFERENCES "MockServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Add mockExamples column to UserRequest
ALTER TABLE "UserRequest"
ADD COLUMN "mockExamples" JSONB;
-- Add mockExamples column to TeamRequest
ALTER TABLE "TeamRequest"
ADD COLUMN "mockExamples" JSONB;
-- Create function to sync mock examples
CREATE OR REPLACE FUNCTION sync_mock_examples()
RETURNS TRIGGER AS $$
BEGIN
NEW."mockExamples" := jsonb_build_object(
'examples',
COALESCE(
(
SELECT jsonb_agg(
jsonb_build_object(
'key', key,
'name', value->>'name',
'endpoint', value->'originalRequest'->>'endpoint',
'method', value->'originalRequest'->>'method',
'headers', COALESCE(value->'originalRequest'->'headers', '[]'::jsonb),
'statusCode', (value->>'code')::int,
'statusText', value->>'status',
'responseBody', value->>'body',
'responseHeaders', COALESCE(value->'headers', '[]'::jsonb)
)
)
FROM jsonb_each(NEW.request->'responses') AS responses(key, value)
WHERE jsonb_typeof(NEW.request->'responses') = 'object'
),
'[]'::jsonb
)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for UserRequest
CREATE TRIGGER trigger_sync_mock_examples_user_request
BEFORE INSERT OR UPDATE OF request ON "UserRequest"
FOR EACH ROW
EXECUTE FUNCTION sync_mock_examples();
-- Create trigger for TeamRequest
CREATE TRIGGER trigger_sync_mock_examples_team_request
BEFORE INSERT OR UPDATE OF request ON "TeamRequest"
FOR EACH ROW
EXECUTE FUNCTION sync_mock_examples();
-- Backfill existing data for UserRequest
UPDATE "UserRequest" SET request = request WHERE request IS NOT NULL;
-- Backfill existing data for TeamRequest
UPDATE "TeamRequest" SET request = request WHERE request IS NOT NULL;
-- Add GIN indexes
CREATE INDEX "idx_mock_examples_user_requests_gin" ON "UserRequest" USING GIN ("mockExamples");
CREATE INDEX "idx_mock_examples_team_requests_gin" ON "TeamRequest" USING GIN ("mockExamples");

View file

@ -1,25 +1,25 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Team {
id String @id @default(cuid())
name String
members TeamMember[]
TeamInvitation TeamInvitation[]
TeamCollection TeamCollection[]
TeamRequest TeamRequest[]
TeamEnvironment TeamEnvironment[]
TeamInvitation TeamInvitation[]
members TeamMember[]
TeamRequest TeamRequest[]
}
model TeamMember {
id String @id @default(uuid()) // Membership ID
id String @id @default(uuid())
role TeamAccessRole
userUid String
teamID String
@ -31,10 +31,10 @@ model TeamMember {
model TeamInvitation {
id String @id @default(cuid())
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
creatorUid String
inviteeEmail String
inviteeRole TeamAccessRole
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
@@unique([teamID, inviteeEmail])
@@index([teamID])
@ -43,16 +43,16 @@ model TeamInvitation {
model TeamCollection {
id String @id @default(cuid())
parentID String?
data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[]
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
requests TeamRequest[]
@@unique([teamID, parentID, orderIndex])
}
@ -60,14 +60,15 @@ model TeamCollection {
model TeamRequest {
id String @id @default(cuid())
collectionID String
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String
request Json
mockExamples Json?
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
@@unique([teamID, collectionID, orderIndex])
}
@ -75,21 +76,21 @@ model TeamRequest {
model Shortcode {
id String @id @unique
request Json
embedProperties Json?
creatorUid String?
User User? @relation(fields: [creatorUid], references: [uid])
createdOn DateTime @default(now()) @db.Timestamptz(3)
embedProperties Json?
updatedOn DateTime @default(now()) @updatedAt @db.Timestamptz(3)
User User? @relation(fields: [creatorUid], references: [uid])
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
@@unique([id, creatorUid], name: "creator_uid_shortcode_unique")
}
model TeamEnvironment {
id String @id @default(cuid())
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
name String
variables Json
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
}
model User {
@ -99,100 +100,97 @@ model User {
photoURL String?
isAdmin Boolean @default(false)
refreshToken String?
providerAccounts Account[]
VerificationToken VerificationToken[]
settings UserSettings?
UserHistory UserHistory[]
UserEnvironments UserEnvironment[]
userCollections UserCollection[]
userRequests UserRequest[]
currentRESTSession Json?
currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamptz(3)
lastLoggedOn DateTime? @db.Timestamptz(3)
lastActiveOn DateTime? @db.Timestamptz(3)
createdOn DateTime @default(now()) @db.Timestamptz(3)
providerAccounts Account[]
invitedUsers InvitedUsers[]
shortcodes Shortcode[]
mockServers MockServer[]
personalAccessTokens PersonalAccessToken[]
shortcodes Shortcode[]
userCollections UserCollection[]
UserEnvironments UserEnvironment[]
UserHistory UserHistory[]
userRequests UserRequest[]
settings UserSettings?
VerificationToken VerificationToken[]
}
model Account {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [uid], onDelete: Cascade)
provider String
providerAccountId String
providerRefreshToken String?
providerAccessToken String?
providerScope String?
loggedIn DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [userId], references: [uid], onDelete: Cascade)
@@unique(fields: [provider, providerAccountId], name: "verifyProviderAccount")
@@unique([provider, providerAccountId], name: "verifyProviderAccount")
}
model VerificationToken {
deviceIdentifier String
token String @unique @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
expiresOn DateTime @db.Timestamptz(3)
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
@@unique(fields: [deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens")
@@unique([deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens")
}
model UserSettings {
id String @id @default(cuid())
userUid String @unique
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
properties Json
updatedOn DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
}
model UserHistory {
id String @id @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
reqType ReqType
request Json
responseMetadata Json
isStarred Boolean
executedOn DateTime @default(now()) @db.Timestamptz(3)
}
enum ReqType {
REST
GQL
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
}
model UserEnvironment {
id String @id @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
name String?
variables Json
isGlobal Boolean
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
}
model InvitedUsers {
adminUid String
user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade)
adminEmail String
inviteeEmail String @unique
invitedOn DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade)
}
model UserRequest {
id String @id @default(cuid())
userCollection UserCollection @relation(fields: [collectionID], references: [id])
collectionID String
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String
request Json
mockExamples Json?
type ReqType
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
userCollection UserCollection @relation(fields: [collectionID], references: [id])
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
@@unique([userUid, collectionID, orderIndex])
}
@ -200,46 +198,40 @@ model UserRequest {
model UserCollection {
id String @id @default(cuid())
parentID String?
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
children UserCollection[] @relation("ParentUserCollection")
requests UserRequest[]
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
title String
data Json?
orderIndex Int
type ReqType
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
data Json?
parent UserCollection? @relation("ParentUserCollection", fields: [parentID], references: [id], onDelete: Cascade)
children UserCollection[] @relation("ParentUserCollection")
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
requests UserRequest[]
@@unique([userUid, parentID, orderIndex])
}
enum TeamAccessRole {
OWNER
VIEWER
EDITOR
}
model InfraConfig {
id String @id @default(cuid())
name String @unique
value String?
lastSyncedEnvFileValue String? // deprecated, use `value` instead
isEncrypted Boolean @default(false) // Use case: Let's say, Admin wants to store a Secret Key, but doesn't want to store it in plain text in `value` column
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
isEncrypted Boolean @default(false)
lastSyncedEnvFileValue String?
}
model PersonalAccessToken {
id String @id @default(cuid())
userUid String
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
label String
token String @unique @default(uuid())
expiresOn DateTime? @db.Timestamptz(3)
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userUid], references: [uid], onDelete: Cascade)
}
model InfraToken {
@ -251,3 +243,79 @@ model InfraToken {
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @default(now()) @db.Timestamptz(3)
}
model MockServer {
id String @id @default(cuid())
name String
subdomain String @unique
creatorUid String?
collectionID String
workspaceType WorkspaceType
workspaceID String
delayInMs Int @default(0)
isPublic Boolean @default(true)
isActive Boolean @default(true)
hitCount Int @default(0)
lastHitAt DateTime? @db.Timestamptz(3)
createdOn DateTime @default(now()) @db.Timestamptz(3)
updatedOn DateTime @updatedAt @db.Timestamptz(3)
deletedAt DateTime? @db.Timestamptz(3)
user User? @relation(fields: [creatorUid], references: [uid], onDelete: SetNull)
requestLogs MockServerLog[]
activityHistory MockServerActivity[]
}
model MockServerLog {
id String @id @default(cuid())
mockServerID String
requestMethod String
requestPath String
requestHeaders Json
requestBody Json?
requestQuery Json?
responseStatus Int
responseHeaders Json
responseBody Json?
responseTime Int
ipAddress String?
userAgent String?
executedAt DateTime @default(now()) @db.Timestamptz(3)
mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade)
@@index([mockServerID])
@@index([mockServerID, executedAt])
}
model MockServerActivity {
id String @id @default(cuid())
mockServerID String
action MockServerAction
performedBy String?
performedAt DateTime @default(now()) @db.Timestamptz(3)
mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade)
@@index([mockServerID])
}
enum WorkspaceType {
USER
TEAM
}
enum ReqType {
REST
GQL
}
enum TeamAccessRole {
OWNER
VIEWER
EDITOR
}
enum MockServerAction {
CREATED
DELETED
ACTIVATED
DEACTIVATED
}

View file

@ -36,6 +36,7 @@ import { InfraTokenModule } from './infra-token/infra-token.module';
import { PrismaModule } from './prisma/prisma.module';
import { PubSubModule } from './pubsub/pubsub.module';
import { SortModule } from './orchestration/sort/sort.module';
import { MockServerModule } from './mock-server/mock-server.module';
@Module({
imports: [
@ -124,6 +125,7 @@ import { SortModule } from './orchestration/sort/sort.module';
AccessTokenModule,
InfraTokenModule,
SortModule,
MockServerModule,
],
providers: [
GQLComplexityPlugin,

View file

@ -878,3 +878,52 @@ export const INFRA_TOKEN_EXPIRED = 'infra_token/expired';
* (InfraTokenService)
*/
export const INFRA_TOKEN_CREATOR_NOT_FOUND = 'infra_token/creator_not_found';
/**
* Mock server not found
* (MockServerService)
*/
export const MOCK_SERVER_NOT_FOUND = 'mock_server/not_found';
/**
* Mock server invalid collection
* (MockServerService)
*/
export const MOCK_SERVER_INVALID_COLLECTION = 'mock_server/invalid_collection';
/**
* Mock server already exists for this collection
* (MockServerService)
*/
export const MOCK_SERVER_ALREADY_EXISTS = 'mock_server/already_exists';
/**
* Mock server creation failed
* (MockServerService)
*/
export const MOCK_SERVER_CREATION_FAILED = 'mock_server/creation_failed';
/**
* Mock server update failed
* (MockServerService)
*/
export const MOCK_SERVER_UPDATE_FAILED = 'mock_server/update_failed';
/**
* Mock server deletion failed
* (MockServerService)
*/
export const MOCK_SERVER_DELETION_FAILED = 'mock_server/deletion_failed';
/**
* Mock server log not found
* (MockServerService)
*/
export const MOCK_SERVER_LOG_NOT_FOUND = 'mock_server/log_not_found';
/**
* Mock server log deletion failed
* (MockServerService)
*/
export const MOCK_SERVER_LOG_DELETION_FAILED =
'mock_server/log_deletion_failed';

View file

@ -32,6 +32,7 @@ import { InfraConfigResolver } from './infra-config/infra-config.resolver';
import { InfraTokenResolver } from './infra-token/infra-token.resolver';
import { SortTeamCollectionResolver } from './orchestration/sort/sort-team-collection.resolver';
import { SortUserCollectionResolver } from './orchestration/sort/sort-user-collection.resolver';
import { MockServerResolver } from './mock-server/mock-server.resolver';
/**
* All the resolvers present in the application.
@ -66,6 +67,7 @@ const RESOLVERS = [
InfraTokenResolver,
SortUserCollectionResolver,
SortTeamCollectionResolver,
MockServerResolver,
];
/**

View file

@ -302,6 +302,11 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
value: 'true',
isEncrypted: false,
},
{
name: InfraConfigEnum.MOCK_SERVER_WILDCARD_DOMAIN,
value: null,
isEncrypted: false,
},
];
return infraConfigDefaultObjs;

View file

@ -678,6 +678,17 @@ export class InfraConfigService implements OnModuleInit {
if (!validateSMTPEmail(value)) return fail();
break;
case InfraConfigEnum.MOCK_SERVER_WILDCARD_DOMAIN:
if (!value) break; // Allow empty value
if (!value.startsWith('*.mock.')) return fail();
// Validate domain format after *.mock.
const domainPart = value.substring(7); // Remove '*.mock.'
const domainRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!domainPart || !domainRegex.test(domainPart)) return fail();
break;
case InfraConfigEnum.MAILER_SMTP_HOST:
case InfraConfigEnum.MAILER_SMTP_PORT:
case InfraConfigEnum.MAILER_SMTP_USER:

View file

@ -0,0 +1,274 @@
import {
CanActivate,
ExecutionContext,
Injectable,
BadRequestException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import { MockServerService } from './mock-server.service';
import * as E from 'fp-ts/Either';
import { AccessTokenService } from 'src/access-token/access-token.service';
import { TeamService } from 'src/team/team.service';
import { WorkspaceType } from '@prisma/client';
/**
* Guard to extract and validate mock server ID from either:
* 1. Subdomain pattern: mock-server-id.mock.hopp.io/product
* 2. Route pattern: backend.hopp.io/mock/mock-server-id/product
*/
@Injectable()
export class MockRequestGuard implements CanActivate {
constructor(
private readonly mockServerService: MockServerService,
private readonly accessTokenService: AccessTokenService,
private readonly teamService: TeamService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
// Extract mock server ID from either subdomain or route
const mockServerSubdomain = this.extractMockServerSubdomain(request);
if (!mockServerSubdomain) {
throw new BadRequestException(
'Invalid mock server request. Mock server ID not found in subdomain or path.',
);
}
// Validate mock server exists (including inactive ones)
const mockServerResult =
await this.mockServerService.getMockServerBySubdomain(
mockServerSubdomain,
true, // includeInactive = true
);
if (E.isLeft(mockServerResult)) {
console.warn(
`Mock server lookup failed for subdomain: ${mockServerSubdomain}, error: ${mockServerResult.left}`,
);
throw new NotFoundException(
`Mock server '${mockServerSubdomain}' not found`,
);
}
const mockServer = mockServerResult.right;
// Check if mock server is active and throw proper error if not
if (!mockServer.isActive) {
throw new BadRequestException(
`Mock server '${mockServerSubdomain}' is currently inactive`,
);
}
if (!mockServer.isPublic) {
const apiKey = request.get('x-api-key');
if (!apiKey) {
throw new BadRequestException(
'API key is required. Please provide x-api-key header.',
);
}
// Validate the Personal Access Token (PAT)
await this.validatePAT(apiKey, mockServer);
}
// Attach mock server info to request for downstream use
(request as any).mockServer = mockServer;
(request as any).mockServerId = mockServer.id;
return true;
}
/**
* Extract mock server ID from request using either subdomain or route-based pattern
*
* Supports two patterns:
* 1. Subdomain: mock-server-id.mock.hopp.io/product mock-server-id (from host)
* After Caddy rewrite: path becomes /mock/product
* 2. Route: backend.hopp.io/mock/mock-server-id/product mock-server-id (from path)
*
* @param request Express request object
* @returns Mock server ID or null if not found
*/
private extractMockServerSubdomain(request: Request): string | null {
const host = request.get('host') || '';
const path = request.path || '/';
// Try subdomain pattern first (Option 1)
// For subdomain-based requests, Caddy rewrites path to /mock/...
// but the mock server ID comes from the subdomain, not the path
const subdomainId = this.extractFromSubdomain(host);
if (subdomainId) {
return subdomainId;
}
// Try route-based pattern (Option 2)
// Only use route extraction if there's no subdomain match
// Route pattern: /mock/mock-server-id/...
const routeId = this.extractFromRoute(path);
if (routeId) {
return routeId;
}
return null;
}
/**
* Extract mock server ID from subdomain pattern
* Supports: mock-server-id.mock.hopp.io or mock-server-id.mock.localhost
*
* @param host Host header value
* @returns Mock server ID or null
*/
private extractFromSubdomain(host: string): string | null {
// Remove port if present
const hostname = host.split(':')[0];
// Split by dots
const parts = hostname.split('.');
// Check if this is a mock subdomain pattern
// For: mock-server-id.mock.hopp.io → ['mock-server-id', 'mock', 'hopp', 'io']
// For: mock-server-id.mock.localhost → ['mock-server-id', 'mock', 'localhost']
if (parts.length >= 3) {
// Check if second part is 'mock'
if (parts[1] === 'mock') {
const mockServerId = parts[0];
// Validate it's not empty and follows a reasonable pattern
if (mockServerId && mockServerId.length > 0) {
return mockServerId;
}
}
}
// Also support: mock-server-id.localhost (for simpler local dev)
if (parts.length === 2 && parts[1] === 'localhost') {
const mockServerId = parts[0];
if (mockServerId && mockServerId.length > 0) {
return mockServerId;
}
}
return null;
}
/**
* Extract mock server ID from route pattern
* Supports: /mock/mock-server-id/product mock-server-id
* Note: Caddy prepends /mock to subdomain requests, so subdomain pattern
* mock-server-id.mock.hopp.io/product becomes /mock/product
*
* @param path Request path
* @returns Mock server ID or null
*/
private extractFromRoute(path: string): string | null {
// Pattern: /mock/mock-server-id/...
// We need to match: /mock/{id} or /mock/{id}/...
const mockPathRegex = /^\/mock\/([^\/]+)/;
const match = path.match(mockPathRegex);
if (match && match[1]) {
const mockServerId = match[1];
// Validate it's not empty and not the word 'mock' itself
if (mockServerId && mockServerId !== 'mock' && mockServerId.length > 0) {
return mockServerId;
}
}
return null;
}
/**
* Validate Personal Access Token (PAT) for private mock server access
*
* Rules:
* - If mock server is in USER workspace: PAT must belong to that user
* - If mock server is in TEAM workspace: PAT creator must be a member of that team
*
* @param apiKey The x-api-key header value (PAT)
* @param mockServer The mock server being accessed
* @throws UnauthorizedException if PAT is invalid or user lacks access
*/
private async validatePAT(apiKey: string, mockServer: any): Promise<void> {
// Get the PAT and associated user
const patResult = await this.accessTokenService.getUserPAT(apiKey);
if (E.isLeft(patResult)) {
throw new UnauthorizedException(
'Invalid or expired API key. Please provide a valid Personal Access Token.',
);
}
const pat = patResult.right;
const userUid = pat.user.uid;
// Check if PAT has expired
if (pat.expiresOn !== null && new Date() > pat.expiresOn) {
throw new UnauthorizedException(
'API key has expired. Please generate a new Personal Access Token.',
);
}
// Validate based on workspace type
if (mockServer.workspaceType === WorkspaceType.USER) {
// For USER workspace: PAT must belong to the workspace owner
if (userUid !== mockServer.workspaceID) {
throw new UnauthorizedException(
'Access denied. This Personal Access Token does not have permission to access this mock server.',
);
}
} else if (mockServer.workspaceType === WorkspaceType.TEAM) {
// For TEAM workspace: PAT creator must be a member of the team
const teamMember = await this.teamService.getTeamMember(
mockServer.workspaceID,
userUid,
);
if (!teamMember) {
throw new UnauthorizedException(
'Access denied. You must be a member of the team to access this mock server.',
);
}
} else {
throw new BadRequestException('Invalid workspace type for mock server.');
}
// Update last used timestamp for the PAT
await this.accessTokenService.updateLastUsedForPAT(apiKey);
}
/**
* Get the actual path without the /mock/mock-server-id prefix
* This is useful for route-based pattern to get the actual endpoint path
*
* @param fullPath Full request path
* @param mockServerId Mock server ID
* @returns Clean path for the mock endpoint
*/
static getCleanPath(fullPath: string, mockServerId: string): string {
// If route-based: /mock/mock-server-id/product → /product
const routePrefix = `/mock/${mockServerId}`;
if (fullPath.startsWith(routePrefix)) {
const cleanPath = fullPath.substring(routePrefix.length);
return cleanPath || '/';
}
// If subdomain-based: Caddy rewrites to /mock/product → /product
// Strip the /mock prefix added by Caddy
if (fullPath.startsWith('/mock/')) {
const cleanPath = fullPath.substring(5); // Remove '/mock'
return cleanPath || '/';
}
// Fallback: return as-is
return fullPath;
}
}

View file

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from 'src/prisma/prisma.service';
import { MockServer as dbMockServer, MockServerAction } from '@prisma/client';
@Injectable()
export class MockServerAnalyticsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Record mock server activity
* @param mockServer The mock server database object
* @param action The action being performed (CREATED, ACTIVATED, DEACTIVATED, DELETED, UPDATED)
* @param performedBy Optional userUid who performed the action
*/
async recordActivity(
mockServer: dbMockServer,
action: MockServerAction,
performedBy?: string,
): Promise<void> {
try {
// Skip if trying to activate an already active server
if (action === MockServerAction.ACTIVATED && mockServer.isActive) {
return;
}
// Skip if trying to deactivate an already inactive server
if (action === MockServerAction.DEACTIVATED && !mockServer.isActive) {
return;
}
await this.prisma.mockServerActivity.create({
data: {
mockServerID: mockServer.id,
action: action,
performedBy: performedBy || null,
},
});
} catch (error) {
// Log error but don't throw - analytics shouldn't break main flow
console.error('Failed to record mock server activity:', error);
}
}
}

View file

@ -0,0 +1,169 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request, Response } from 'express';
import { MockServer } from '@prisma/client';
import { MockServerService } from './mock-server.service';
@Injectable()
export class MockServerLoggingInterceptor implements NestInterceptor {
constructor(private readonly mockServerService: MockServerService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<Request>();
const response = httpContext.getResponse<Response>();
// Capture request start time
const startTime = Date.now();
// Extract mock server info (attached by MockRequestGuard)
const mockServer = (request as any).mockServer as MockServer;
const mockServerId = (request as any).mockServerId as string;
// If no mock server info, skip logging
if (!mockServer || !mockServerId) {
return next.handle();
}
// Capture request details
const requestMethod = request.method;
const requestPath = request.path;
const requestHeaders = this.extractHeaders(request);
const requestBody = request.body || {};
const requestQuery = this.extractQueryParams(request);
if (!requestBody || typeof requestBody !== 'object') {
console.warn('Request body is not properly parsed');
}
// Extract client info
const ipAddress =
(request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
request.socket.remoteAddress ||
undefined;
const userAgent = request.headers['user-agent'] as string | undefined;
// Capture response - use finalize to ensure logging happens regardless of success/error
return next.handle().pipe(
tap({
next: () => {
// Success case - log after response is sent
const responseTime = Date.now() - startTime;
const responseStatus = response.statusCode || 200;
const responseHeaders = this.extractResponseHeaders(response);
// Log the request asynchronously (fire and forget)
this.mockServerService
.logRequest({
mockServerID: mockServerId,
requestMethod,
requestPath,
requestHeaders,
requestBody,
requestQuery,
responseStatus,
responseHeaders,
responseTime,
ipAddress,
userAgent,
})
.catch((err) => console.error('Failed to log mock request:', err));
// Increment hit count asynchronously (fire and forget)
this.mockServerService
.incrementHitCount(mockServerId)
.catch((err) =>
console.error('Failed to increment hit count:', err),
);
},
error: (error) => {
// Error case - log the error but let it propagate to user
const responseTime = Date.now() - startTime;
const responseStatus = error.status || 500;
// Log error response asynchronously
this.mockServerService
.logRequest({
mockServerID: mockServerId,
requestMethod,
requestPath,
requestHeaders,
requestBody,
requestQuery,
responseStatus,
responseHeaders: {},
responseTime,
ipAddress,
userAgent,
})
.catch((err) =>
console.error('Failed to log mock request error:', err),
);
// Still increment hit count even for errors
this.mockServerService
.incrementHitCount(mockServerId)
.catch((err) =>
console.error('Failed to increment hit count:', err),
);
// Error will automatically propagate to user
// No need to re-throw, tap operator handles this
},
}),
);
}
/**
* Extract request headers as a plain object
*/
private extractHeaders(request: Request): Record<string, string> {
const headers: Record<string, string> = {};
Object.keys(request.headers).forEach((key) => {
const value = request.headers[key];
if (typeof value === 'string') {
headers[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
headers[key.toLowerCase()] = value[0];
}
});
return headers;
}
/**
* Extract query parameters as a plain object
*/
private extractQueryParams(
request: Request,
): Record<string, string> | undefined {
const queryParams = request.query as Record<string, string>;
return Object.keys(queryParams).length > 0 ? queryParams : undefined;
}
/**
* Extract response headers as a plain object
*/
private extractResponseHeaders(response: Response): Record<string, string> {
const headers: Record<string, string> = {};
const headerNames = response.getHeaderNames();
headerNames.forEach((name) => {
const value = response.getHeader(name);
if (typeof value === 'string') {
headers[name.toLowerCase()] = value;
} else if (typeof value === 'number') {
headers[name.toLowerCase()] = value.toString();
} else if (Array.isArray(value)) {
headers[name.toLowerCase()] = value.join(', ');
}
});
return headers;
}
}

View file

@ -0,0 +1,124 @@
import {
Controller,
All,
Req,
Res,
HttpStatus,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { MockServerService } from './mock-server.service';
import { MockServerLoggingInterceptor } from './mock-server-logging.interceptor';
import * as E from 'fp-ts/Either';
import { MockRequestGuard } from './mock-request.guard';
import { MockServer } from '@prisma/client';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
/**
* Mock server controller with dual routing support:
* 1. Subdomain pattern: mock-server-id.mock.hopp.io/product
* 2. Route pattern: backend.hopp.io/mock/mock-server-id/product
*
* The MockRequestGuard handles extraction of mock server ID from both patterns
* The MockServerLoggingInterceptor handles logging of all requests
*/
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'mock' })
export class MockServerController {
constructor(private readonly mockServerService: MockServerService) {}
@All('*path')
@UseGuards(MockRequestGuard)
@UseInterceptors(MockServerLoggingInterceptor)
async handleMockRequest(@Req() req: Request, @Res() res: Response) {
// Mock server ID and info are attached by the guard
const mockServerId = (req as any).mockServerId as string;
const mockServer = (req as any).mockServer as MockServer;
if (!mockServerId) {
return res.status(HttpStatus.NOT_FOUND).json({
error: 'Not found',
message: 'Mock server ID not found',
});
}
const method = req.method;
// Get clean path (removes /mock/mock-server-id prefix for route-based pattern)
const path = MockRequestGuard.getCleanPath(
req.path || '/',
mockServer.subdomain,
);
// Extract query parameters
const queryParams = req.query as Record<string, string>;
// Extract request headers (convert to lowercase for case-insensitive matching)
const requestHeaders: Record<string, string> = {};
Object.keys(req.headers).forEach((key) => {
const value = req.headers[key];
if (typeof value === 'string') {
requestHeaders[key.toLowerCase()] = value;
} else if (Array.isArray(value)) {
requestHeaders[key.toLowerCase()] = value[0];
}
});
try {
const result = await this.mockServerService.handleMockRequest(
mockServer,
path,
method,
queryParams,
requestHeaders,
);
if (E.isLeft(result)) {
return res.status(HttpStatus.NOT_FOUND).json({
error: 'Endpoint not found',
message: result.left,
});
}
const mockResponse = result.right;
// Set response headers if any
if (mockResponse.headers) {
try {
const headers = JSON.parse(mockResponse.headers);
Object.keys(headers).forEach((key) => {
res.setHeader(key, headers[key]);
});
} catch (error) {
console.error('Error parsing response headers:', error);
}
}
// Add delay if specified
if (mockServer.delayInMs && mockServer.delayInMs > 0) {
await new Promise((resolve) =>
setTimeout(resolve, mockServer.delayInMs),
);
}
// Send response
const defaultContentType =
typeof mockResponse.body === 'object'
? 'application/json'
: 'text/plain';
const contentType =
mockResponse.headers?.['content-type'] || defaultContentType;
res.setHeader('Content-Type', contentType);
res.setHeader('X-Content-Type-Options', 'nosniff');
return res.status(mockResponse.statusCode).send(mockResponse.body);
} catch (error) {
console.error('Error handling mock request:', error);
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
error: 'Internal server error',
message: 'Failed to process mock request',
});
}
}
}

View file

@ -0,0 +1,294 @@
import {
Field,
ID,
ObjectType,
ArgsType,
InputType,
registerEnumType,
} from '@nestjs/graphql';
import {
IsAlphanumeric,
IsNumber,
IsOptional,
Max,
MaxLength,
MinLength,
} from 'class-validator';
import { WorkspaceType } from 'src/types/WorkspaceTypes';
@ObjectType()
export class MockServer {
@Field(() => ID, {
description: 'ID of the mock server',
})
id: string;
@Field({
description: 'Name of the mock server',
})
name: string;
@Field({
description: 'Subdomain for the mock server (e.g., mock-1234)',
})
subdomain: string;
@Field({
nullable: true,
description:
'Server URL for the mock server using subdomain pattern (e.g., https://1234.mock.backend-hoppscotch.io)',
})
serverUrlDomainBased: string;
@Field({
description:
'Server URL for the mock server using path pattern (e.g., https://backend.hoppscotch.io/mock/1234)',
})
serverUrlPathBased: string;
@Field(() => WorkspaceType, {
description: 'Type of workspace: USER or TEAM',
})
workspaceType: WorkspaceType;
@Field({
nullable: true,
description:
'ID of the workspace (user or team) to associate with the mock server',
})
workspaceID?: string;
@Field({
description: 'Delay in milliseconds before responding',
})
delayInMs: number;
@Field({
description: 'Whether the mock server is active',
})
isActive: boolean;
@Field({
description: 'Whether the mock server is publicly accessible',
})
isPublic: boolean;
@Field({
description: 'Date and time when the mock server was created',
})
createdOn: Date;
@Field({
description: 'Date and time when the mock server was last updated',
})
updatedOn: Date;
}
@ObjectType()
export class MockServerCollection {
@Field(() => ID, {
description: 'ID of the collection',
})
id: string;
@Field({
description: 'Title of the collection',
})
title: string;
}
@InputType()
export class CreateMockServerInput {
@Field({
description: 'Name of the mock server',
})
@MinLength(1)
@MaxLength(255)
@IsAlphanumeric()
name: string;
@Field({
description:
'ID of the (team or user) collection to associate with the mock server',
})
collectionID: string;
@Field(() => WorkspaceType, {
description: 'Type of workspace: USER or TEAM',
})
workspaceType: WorkspaceType;
@Field({
nullable: true,
description:
'ID of the workspace (user or team) to associate with the mock server',
})
workspaceID?: string;
@Field({
nullable: true,
defaultValue: 0,
description: 'Delay in milliseconds before responding',
})
@IsOptional()
@IsNumber()
@Max(60000)
delayInMs?: number;
@Field({
nullable: true,
defaultValue: true,
description: 'Whether the mock server is publicly accessible',
})
isPublic?: boolean;
}
@InputType()
export class UpdateMockServerInput {
@Field({
nullable: true,
description: 'Name of the mock server',
})
@IsOptional()
@MinLength(1)
@MaxLength(255)
@IsAlphanumeric()
name?: string;
@Field({
nullable: true,
description: 'Delay in milliseconds before responding',
})
@IsOptional()
@IsNumber()
@Max(60000)
delayInMs?: number;
@Field({
nullable: true,
description: 'Whether the mock server is active',
})
isActive?: boolean;
@Field({
nullable: true,
description: 'Whether the mock server is publicly accessible',
})
isPublic?: boolean;
}
@ObjectType()
export class MockServerResponse {
@Field({
description: 'HTTP status code to return',
})
statusCode: number;
@Field({
nullable: true,
description: 'Response body to return',
})
body?: string;
@Field({
nullable: true,
description: 'Response headers as JSON string',
})
headers?: string;
@Field({
defaultValue: 0,
description: 'Delay in milliseconds before response',
})
delay: number;
}
@ArgsType()
export class MockServerMutationArgs {
@Field(() => ID, {
description: 'ID of the mock server',
})
id: string;
}
@ObjectType()
export class MockServerLog {
@Field(() => ID, {
description: 'ID of the log entry',
})
id: string;
@Field(() => ID, {
description: 'ID of the mock server',
})
mockServerID: string;
@Field({
description: 'HTTP method of the request',
})
requestMethod: string;
@Field({
description: 'Path of the request',
})
requestPath: string;
@Field({
description: 'Request headers as JSON string',
})
requestHeaders: string;
@Field({
nullable: true,
description: 'Request body as JSON string',
})
requestBody?: string;
@Field({
nullable: true,
description: 'Request query parameters as JSON string',
})
requestQuery?: string;
@Field({
description: 'HTTP status code of the response',
})
responseStatus: number;
@Field({
description: 'Response headers as JSON string',
})
responseHeaders: string;
@Field({
nullable: true,
description: 'Response body as JSON string',
})
responseBody?: string;
@Field({
description: 'Response time in milliseconds',
})
responseTime: number;
@Field({
nullable: true,
description: 'IP address of the requester',
})
ipAddress?: string;
@Field({
nullable: true,
description: 'User agent of the requester',
})
userAgent?: string;
@Field({
description: 'Date and time when the request was executed',
})
executedAt: Date;
}
registerEnumType(WorkspaceType, {
name: 'WorkspaceType',
});

View file

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from 'src/prisma/prisma.module';
import { MockServerService } from './mock-server.service';
import { MockServerAnalyticsService } from './mock-server-analytics.service';
import { MockServerLoggingInterceptor } from './mock-server-logging.interceptor';
import { MockServerResolver } from './mock-server.resolver';
import { TeamModule } from 'src/team/team.module';
import { TeamRequestModule } from 'src/team-request/team-request.module';
import { MockServerController } from './mock-server.controller';
import { AccessTokenModule } from 'src/access-token/access-token.module';
@Module({
imports: [PrismaModule, TeamModule, TeamRequestModule, AccessTokenModule],
controllers: [MockServerController],
providers: [
MockServerService,
MockServerAnalyticsService,
MockServerLoggingInterceptor,
MockServerResolver,
],
})
export class MockServerModule {}

View file

@ -0,0 +1,222 @@
import {
Resolver,
Query,
Mutation,
Args,
ID,
ResolveField,
Parent,
} from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { User } from 'src/user/user.model';
import { MockServerService } from './mock-server.service';
import {
MockServer,
CreateMockServerInput,
UpdateMockServerInput,
MockServerMutationArgs,
MockServerCollection,
MockServerLog,
} from './mock-server.model';
import * as E from 'fp-ts/Either';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard';
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
import { TeamAccessRole } from 'src/team/team.model';
import { throwErr } from 'src/utils';
import { MockServerAnalyticsService } from './mock-server-analytics.service';
@Resolver(() => MockServer)
export class MockServerResolver {
constructor(
private readonly mockServerService: MockServerService,
private readonly mockServerAnalyticsService: MockServerAnalyticsService,
) {}
// Resolve Fields
@ResolveField(() => User, {
nullable: true,
description: 'Returns the creator of the mock server',
})
async creator(@Parent() mockServer: MockServer): Promise<User> {
const creator = await this.mockServerService.getMockServerCreator(
mockServer.id,
);
if (E.isLeft(creator)) throwErr(creator.left);
return {
...creator.right,
currentGQLSession: JSON.stringify(creator.right.currentGQLSession),
currentRESTSession: JSON.stringify(creator.right.currentRESTSession),
};
}
@ResolveField(() => MockServerCollection, {
description: 'Returns the collection of the mock server',
})
async collection(
@Parent() mockServer: MockServer,
): Promise<MockServerCollection> {
const collection = await this.mockServerService.getMockServerCollection(
mockServer.id,
);
if (E.isLeft(collection)) throwErr(collection.left);
return collection.right;
}
// Queries
@Query(() => [MockServer], {
description: 'Get all mock servers for the authenticated user',
})
@UseGuards(GqlAuthGuard)
async myMockServers(
@GqlUser() user: User,
@Args() args: OffsetPaginationArgs,
): Promise<MockServer[]> {
return this.mockServerService.getUserMockServers(user.uid, args);
}
@Query(() => [MockServer], {
description: 'Get all mock servers for a specific team',
})
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(
TeamAccessRole.VIEWER,
TeamAccessRole.EDITOR,
TeamAccessRole.OWNER,
)
async teamMockServers(
@Args({
name: 'teamID',
type: () => ID,
description: 'Id of the team to add to',
})
teamID: string,
@Args() args: OffsetPaginationArgs,
): Promise<MockServer[]> {
return this.mockServerService.getTeamMockServers(teamID, args);
}
@Query(() => MockServer, {
description: 'Get a specific mock server by ID',
})
@UseGuards(GqlAuthGuard)
async mockServer(
@GqlUser() user: User,
@Args({
name: 'id',
type: () => ID,
description: 'Id of the mock server to retrieve',
})
id: string,
): Promise<MockServer> {
const result = await this.mockServerService.getMockServer(id, user.uid);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
@Query(() => [MockServerLog], {
description:
'Get logs for a specific mock server with pagination, sorted by execution time (most recent first)',
})
@UseGuards(GqlAuthGuard)
async mockServerLogs(
@GqlUser() user: User,
@Args({
name: 'mockServerID',
type: () => ID,
description: 'ID of the mock server',
})
mockServerID: string,
@Args() args: OffsetPaginationArgs,
): Promise<MockServerLog[]> {
const result = await this.mockServerService.getMockServerLogs(
mockServerID,
user.uid,
args,
);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
// Mutations
@Mutation(() => MockServer, {
description: 'Create a new mock server',
})
@UseGuards(GqlAuthGuard)
async createMockServer(
@Args('input') input: CreateMockServerInput,
@GqlUser() user: User,
): Promise<MockServer> {
const result = await this.mockServerService.createMockServer(user, input);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
@Mutation(() => MockServer, {
description: 'Update a mock server',
})
@UseGuards(GqlAuthGuard)
async updateMockServer(
@GqlUser() user: User,
@Args() args: MockServerMutationArgs,
@Args('input') input: UpdateMockServerInput,
): Promise<MockServer> {
const result = await this.mockServerService.updateMockServer(
args.id,
user.uid,
input,
);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
@Mutation(() => Boolean, {
description: 'Delete a mock server',
})
@UseGuards(GqlAuthGuard)
async deleteMockServer(
@GqlUser() user: User,
@Args() args: MockServerMutationArgs,
): Promise<boolean> {
const result = await this.mockServerService.deleteMockServer(
args.id,
user.uid,
);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
@Mutation(() => Boolean, {
description: 'Delete a mock server log by log ID',
})
@UseGuards(GqlAuthGuard)
async deleteMockServerLog(
@GqlUser() user: User,
@Args({
name: 'logID',
type: () => ID,
description: 'Id of the log to delete',
})
logID: string,
): Promise<boolean> {
const result = await this.mockServerService.deleteMockServerLog(
logID,
user.uid,
);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { mockDeep, mockReset } from 'jest-mock-extended';
import { mockDeep } from 'jest-mock-extended';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { TeamCollectionService } from 'src/team-collection/team-collection.service';
import { TeamRequestService } from 'src/team-request/team-request.service';

View file

@ -27,7 +27,7 @@ import {
escapeSqlLikeString,
isValidLength,
transformCollectionData,
stringToJson
stringToJson,
} from 'src/utils';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';

View file

@ -55,6 +55,7 @@ for (let i = 1; i <= 10; i++) {
collectionID: teamCollection.id,
teamID: team.id,
request: {},
mockExamples: {},
title: `Test Request ${i}`,
orderIndex: i,
createdOn: new Date(),

View file

@ -48,4 +48,6 @@ export enum InfraConfigEnum {
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
USER_HISTORY_STORE_ENABLED = 'USER_HISTORY_STORE_ENABLED',
MOCK_SERVER_WILDCARD_DOMAIN = 'MOCK_SERVER_WILDCARD_DOMAIN',
}

View file

@ -0,0 +1,4 @@
export enum WorkspaceType {
USER = 'USER',
TEAM = 'TEAM',
}

View file

@ -51,6 +51,7 @@ const dbUserRequests: DbUserRequest[] = [
userUid: user.uid,
title: 'Request 1',
request: {},
mockExamples: {},
type: DbRequestType.REST,
createdOn: new Date(),
updatedOn: new Date(),
@ -62,6 +63,7 @@ const dbUserRequests: DbUserRequest[] = [
userUid: user.uid,
title: 'Request 2',
request: {},
mockExamples: {},
type: DbRequestType.REST,
createdOn: new Date(),
updatedOn: new Date(),
@ -73,6 +75,7 @@ const dbUserRequests: DbUserRequest[] = [
userUid: user.uid,
title: 'Request 3',
request: {},
mockExamples: {},
type: DbRequestType.REST,
createdOn: new Date(),
updatedOn: new Date(),
@ -84,6 +87,7 @@ const dbUserRequests: DbUserRequest[] = [
userUid: user.uid,
title: 'Request 4',
request: {},
mockExamples: {},
type: DbRequestType.REST,
createdOn: new Date(),
updatedOn: new Date(),
@ -95,6 +99,7 @@ const dbUserRequests: DbUserRequest[] = [
userUid: user.uid,
title: 'Request 1',
request: {},
mockExamples: {},
type: DbRequestType.REST,
createdOn: new Date(),
updatedOn: new Date(),
@ -106,6 +111,7 @@ const dbUserRequests: DbUserRequest[] = [
userUid: user.uid,
title: 'Request 2',
request: {},
mockExamples: {},
type: DbRequestType.REST,
createdOn: new Date(),
updatedOn: new Date(),
@ -117,6 +123,7 @@ const dbUserRequests: DbUserRequest[] = [
userUid: user.uid,
title: 'Request 3',
request: {},
mockExamples: {},
type: DbRequestType.REST,
createdOn: new Date(),
updatedOn: new Date(),
@ -128,6 +135,7 @@ const dbUserRequests: DbUserRequest[] = [
userUid: user.uid,
title: 'Request 4',
request: {},
mockExamples: {},
type: DbRequestType.REST,
createdOn: new Date(),
updatedOn: new Date(),

View file

@ -12,7 +12,7 @@ import {
USERS_NOT_FOUND,
USER_NOT_FOUND,
USER_SHORT_DISPLAY_NAME,
USER_UPDATE_FAILED
USER_UPDATE_FAILED,
} from 'src/errors';
import { SessionType, User } from './user.model';
import { PubSubService } from 'src/pubsub/pubsub.service';

View file

@ -367,7 +367,8 @@
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"save_unsaved_tab": "Do you want to save changes made in this tab?",
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress.",
"delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?"
"delete_access_token": "Are you sure you want to delete the access token {tokenLabel}?",
"delete_mock_server": "Are you sure you want to delete this mock server?"
},
"context_menu": {
"add_parameters": "Add to parameters",
@ -439,7 +440,8 @@
"teams": "You don't belong to any workspaces",
"tests": "There are no tests for this request",
"access_tokens": "Access tokens are empty",
"response": "No response received"
"response": "No response received",
"mock_servers": "No mock servers found"
},
"environment": {
"heading": "Environment",
@ -847,10 +849,55 @@
"profile": "Profile",
"realtime": "Realtime",
"rest": "REST",
"mock_servers": "Mock Servers",
"settings": "Settings",
"goto_app": "Goto App",
"authentication": "Authentication"
},
"mock_server": {
"create_mock_server": "Configure Mock Server",
"mock_server_configuration": "Mock Server Configuration",
"mock_server_name": "Mock Server Name",
"mock_server_name_placeholder": "Enter mock server name",
"base_url": "Base URL",
"start_server": "Start Server",
"stop_server": "Stop Server",
"mock_server_created": "Mock server created successfully",
"mock_server_started": "Mock server started successfully",
"mock_server_stopped": "Mock server stopped successfully",
"active": "Mock server is active",
"inactive": "Mock server is inactive",
"edit_mock_server": "Edit Mock Server",
"path_based_url": "Path based URL",
"subdomain_based_url": "Subdomain based URL",
"mock_server_updated": "Mock server updated successfully",
"no_collection": "No collection",
"status": "Status",
"server_running": "Server is running",
"server_stopped": "Server is stopped",
"delay_ms": "Response Delay (ms)",
"delay_placeholder": "Enter delay in milliseconds",
"delay_description": "Add artificial delay to mock responses",
"public_access": "Public Access",
"public": "Public",
"private": "Private",
"public_description": "Anyone with the URL can access this mock server",
"private_description": "Only authenticated users can access this mock server",
"select_collection": "Select a collection",
"select_collection_error": "Please select a collection",
"url_copied": "URL copied to clipboard",
"make_public": "Make Public",
"view_logs": "View logs",
"logs_title": "Mock Server Logs",
"no_logs": "No logs available",
"request_headers": "Request Headers",
"request_body": "Request Body",
"response_status": "Response Status",
"response_headers": "Response Headers",
"response_body": "Response Body",
"log_deleted": "Log deleted successfully",
"description": "Mock servers allow you to simulate API responses based on your collection's example responses."
},
"preRequest": {
"javascript_code": "JavaScript Code",
"learn": "Read documentation",
@ -1481,6 +1528,7 @@
"shared_requests": "Shared Requests",
"codegen": "Generate Code",
"code_snippet": "Code snippet",
"mock_servers": "Mock Servers",
"share_tab_request": "Share tab request",
"socketio": "Socket.IO",
"sse": "SSE",
@ -1947,5 +1995,63 @@
"app_console": {
"entries": "Console entries",
"no_entries": "No entries"
},
"mockServer": {
"create_modal": {
"title": "Create Mock Server",
"name_label": "Mock Server Name",
"name_placeholder": "Enter mock server name",
"name_required": "Mock server name is required",
"collection_source_label": "Collection Source",
"existing_collection": "Existing Collection",
"new_collection": "New Collection",
"select_collection_label": "Select Collection",
"select_collection_placeholder": "Choose a collection",
"collection_required": "Please select a collection",
"collection_name_label": "Collection Name",
"collection_name_placeholder": "Enter collection name",
"collection_name_required": "Collection name is required",
"request_config_label": "Request Configuration",
"add_request": "Add Request",
"create_button": "Create Mock Server",
"success": "Mock server created successfully",
"error": "Failed to create mock server",
"no_collections": "No collections available"
},
"edit_modal": {
"title": "Edit Mock Server",
"name_label": "Mock Server Name",
"name_placeholder": "Enter mock server name",
"name_required": "Mock server name is required",
"active_label": "Active",
"url_label": "Mock Server URL",
"collection_label": "Collection",
"update_button": "Update Mock Server",
"success": "Mock server updated successfully",
"error": "Failed to update mock server",
"url_copied": "URL copied to clipboard"
},
"dashboard": {
"title": "Mock Servers",
"subtitle": "Create and manage your API mock servers",
"create_button": "Create Mock Server",
"create_first": "Create your first mock server",
"empty_title": "No mock servers found",
"empty_description": "Create mock servers based on your API collections to enable frontend and mobile development without backend dependencies.",
"collection": "Collection",
"active": "Active",
"inactive": "Inactive",
"mock_url": "Mock URL",
"endpoints": "Endpoints",
"created": "Created",
"view_collection": "View Collection",
"documentation": "Documentation",
"doc_description": "Use this URL as your API base URL in your applications:",
"url_copied": "Mock server URL copied to clipboard",
"delete_title": "Delete Mock Server",
"delete_description": "Are you sure you want to delete this mock server?",
"delete_success": "Mock server deleted successfully",
"delete_error": "Failed to delete mock server"
}
}
}

View file

@ -143,6 +143,8 @@ declare module 'vue' {
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSelect: typeof import('@hoppscotch/ui')['HoppSmartSelect']
HoppSmartSelectOption: typeof import('@hoppscotch/ui')['HoppSmartSelectOption']
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
@ -256,6 +258,10 @@ declare module 'vue' {
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
MockServerCreateMockServer: typeof import('./components/mockServer/CreateMockServer.vue')['default']
MockServerEditMockServer: typeof import('./components/mockServer/EditMockServer.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']
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']

View file

@ -56,6 +56,26 @@
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collectionName }}
</span>
<!-- Mock Server Status Indicator -->
<span
v-if="mockServerStatus.exists"
v-tippy="{ theme: 'tooltip' }"
:title="
mockServerStatus.isActive
? t('mock_server.active')
: t('mock_server.inactive')
"
class="ml-2 flex items-center"
>
<component
:is="IconServer"
class="svg-icons"
:class="{
'text-green-500': mockServerStatus.isActive,
'text-secondaryLight': !mockServerStatus.isActive,
}"
/>
</span>
</span>
</div>
<div
@ -115,6 +135,7 @@
@keyup.p="propertiesAction?.$el.click()"
@keyup.t="runCollectionAction?.$el.click()"
@keyup.s="sortAction?.$el.click()"
@keyup.m="mockServerAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
@ -155,6 +176,19 @@
}
"
/>
<HoppSmartItem
v-if="!hasNoTeamAccess && isRootCollection"
ref="mockServerAction"
:icon="IconServer"
:label="t('mock_server.create_mock_server')"
:shortcut="['M']"
@click="
() => {
handleMockServerAction()
hide()
}
"
/>
<HoppSmartItem
v-if="!hasNoTeamAccess"
ref="edit"
@ -280,11 +314,15 @@ import IconFolderOpen from "~icons/lucide/folder-open"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconPlaySquare from "~icons/lucide/play-square"
import IconServer from "~icons/lucide/server"
import IconSettings2 from "~icons/lucide/settings-2"
import IconTrash2 from "~icons/lucide/trash-2"
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 { platform } from "~/platform"
import { invokeAction } from "~/helpers/actions"
type CollectionType = "my-collections" | "team-collections"
type FolderType = "collection" | "folder"
@ -337,6 +375,7 @@ const emit = defineEmits<{
(event: "duplicate-collection"): void
(event: "export-data"): void
(event: "remove-collection"): void
(event: "create-mock-server"): void
(event: "drop-event", payload: DataTransfer): void
(event: "drag-event", payload: DataTransfer): void
(event: "dragging", payload: boolean): void
@ -360,6 +399,7 @@ const edit = ref<HTMLButtonElement | null>(null)
const duplicateAction = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null)
const mockServerAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const propertiesAction = ref<HTMLButtonElement | null>(null)
const runCollectionAction = ref<HTMLButtonElement | null>(null)
@ -415,6 +455,23 @@ const isCollectionLoading = computed(() => {
return props.teamLoadingCollections!.includes(props.id)
})
// Mock Server Status
const { getMockServerStatus } = useMockServerStatus()
const mockServerStatus = computed(() => {
const collectionId =
props.collectionsType === "my-collections"
? (props.data as HoppCollection).id
: (props.data as TeamCollection).id
return getMockServerStatus(collectionId || "")
})
// Determine if this is a root collection (not a child folder)
const isRootCollection = computed(() => {
return props.folderType === "collection"
})
// Used to determine if the collection is being dragged to a different destination
// This is used to make the highlight effect work
watch(
@ -580,6 +637,19 @@ const sortCollection = () => {
})
}
const handleMockServerAction = () => {
const currentUser = platform.auth.getCurrentUser()
if (!currentUser) {
// Show login modal if user is not authenticated
invokeAction("modals.login.toggle")
return
}
// User is authenticated, proceed with mock server creation
emit("create-mock-server")
}
const resetDragState = () => {
dragging.value = false
ordering.value = false

View file

@ -99,6 +99,13 @@
collection: node.data.data.data,
})
"
@create-mock-server="
node.data.type === 'collections' &&
emit('create-mock-server', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
@ -647,6 +654,13 @@ const emit = defineEmits<{
(event: "select", payload: Picked | null): void
(event: "display-modal-import-export"): void
(event: "select-response", payload: ResponsePayload): void
(
event: "create-mock-server",
payload: {
collectionIndex: string
collection: HoppCollection
}
): void
}>()
const refFilterCollection = toRef(props, "filteredCollections")

View file

@ -118,6 +118,13 @@
collection: node.data.data.data,
})
"
@create-mock-server="
node.data.type === 'collections' &&
emit('create-mock-server', {
collectionID: node.data.data.data.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
@ -716,6 +723,13 @@ const emit = defineEmits<{
event: "run-collection",
payload: { collectionID: string; path: string }
): void
(
event: "create-mock-server",
payload: {
collectionID: string
collection: TeamCollection
}
): void
}>()
const currentSortValuesService = useService(CurrentSortValuesService)

View file

@ -56,6 +56,7 @@
@duplicate-request="duplicateRequest"
@duplicate-response="duplicateResponse"
@edit-properties="editProperties"
@create-mock-server="createMockServer"
@export-data="exportData"
@remove-collection="removeCollection"
@remove-folder="removeFolder"
@ -104,6 +105,7 @@
@edit-request="editRequest"
@edit-response="editResponse"
@edit-properties="editProperties"
@create-mock-server="createTeamMockServer"
@export-data="exportData"
@expand-team-collection="expandTeamCollection"
@remove-collection="removeCollection"
@ -220,6 +222,8 @@
:collection-runner-data="collectionRunnerData"
@hide-modal="showCollectionsRunnerModal = false"
/>
<MockServerCreateMockServer />
</div>
</template>
@ -1073,6 +1077,51 @@ const updateEditingCollection = async (newName: string) => {
}
}
const createMockServer = (payload: {
collectionIndex: string
collection: HoppCollection
}) => {
// 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
// If this is a child collection (folder), we need to get the root collection ID
if (payload.collectionIndex.includes("/")) {
// Extract the root collection index from the path (e.g., "0/1/2" -> "0")
const rootIndex = payload.collectionIndex.split("/")[0]
const rootCollection = myCollections.value[parseInt(rootIndex)]
if (rootCollection) {
collectionID = rootCollection.id || rootCollection._ref_id || rootIndex
}
}
showCreateMockServerModal$.next({
show: true,
collectionID: collectionID,
collectionName: payload.collection.name,
})
})
}
const createTeamMockServer = (payload: {
collectionID: string
collection: TeamCollection
}) => {
// Import the mock server store dynamically to avoid circular dependencies
import("~/newstore/mockServers").then(({ showCreateMockServerModal$ }) => {
showCreateMockServerModal$.next({
show: true,
collectionID: payload.collectionID,
collectionName: payload.collection.title,
})
})
}
const editFolder = (payload: {
folderPath: string | undefined
folder: HoppCollection | TeamCollection

View file

@ -51,6 +51,18 @@
class="px-4 mt-4"
/>
</HoppSmartTab>
<HoppSmartTab
:id="'mock-servers'"
:icon="IconServer"
:label="`${t('tab.mock_servers')}`"
>
<div
class="flex items-center overflow-x-auto whitespace-nowrap border-b border-dividerLight px-4 py-2 text-tiny text-secondaryLight"
>
<span class="truncate"> {{ t("tab.mock_servers") }} </span>
</div>
<MockServerDashboard v-if="selectedNavigationTab === 'mock-servers'" />
</HoppSmartTab>
</HoppSmartTabs>
</template>
@ -60,8 +72,10 @@ import IconLayers from "~icons/lucide/layers"
import IconFolder from "~icons/lucide/folder"
import IconShare2 from "~icons/lucide/share-2"
import IconCode from "~icons/lucide/code"
import IconServer from "~icons/lucide/server"
import { ref } from "vue"
import { useI18n } from "@composables/i18n"
import MockServerDashboard from "~/components/mockServer/MockServerDashboard.vue"
const t = useI18n()
@ -71,6 +85,7 @@ type RequestOptionTabs =
| "env"
| "share-request"
| "codegen"
| "mock-servers"
const selectedNavigationTab = ref<RequestOptionTabs>("collections")
</script>

View file

@ -0,0 +1,550 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="
isExistingMockServer
? t('mock_server.mock_server_configuration')
: t('mock_server.create_mock_server')
"
@close="closeModal"
>
<template #body>
<div class="flex flex-col space-y-6">
<!-- Collection Selector or Info -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("collection.title") }}
</label>
<!-- Collection Selector (when no collection is pre-selected) -->
<div v-if="!collectionID && !isExistingMockServer" class="flex">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions?.focus()"
>
<HoppSmartSelectWrapper>
<HoppButtonSecondary
class="flex flex-1 !justify-start rounded-none pr-8"
:label="
selectedCollectionName || t('mock_server.select_collection')
"
outline
/>
</HoppSmartSelectWrapper>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartLink
v-for="option in collectionOptions"
:key="option.value"
class="flex flex-1"
:class="{
'opacity-50 cursor-not-allowed': option.disabled,
}"
@click="
() => {
if (!option.disabled) {
selectCollection(option)
hide()
}
}
"
>
<HoppSmartItem
:label="option.label"
:active-info-icon="selectedCollectionID === option.value"
:info-icon="
selectedCollectionID === option.value
? IconCheck
: option.hasMockServer
? IconServer
: null
"
:disabled="option.disabled"
/>
</HoppSmartLink>
<div
v-if="collectionOptions.length === 0"
class="flex items-center justify-center px-4 py-8 text-secondaryLight"
>
{{ t("empty.collections") }}
</div>
</div>
</template>
</tippy>
</div>
<!-- Collection Info (when collection is pre-selected) -->
<div v-else class="text-body text-secondary">
{{ collectionName }}
</div>
</div>
<!-- Existing Mock Server Info -->
<div v-if="isExistingMockServer" class="flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.mock_server_name") }}
</label>
<div class="text-body text-secondary">
{{ existingMockServer?.name }}
</div>
</div>
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.base_url") }}
</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"
>
{{ mockServerBaseUrl }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="copyToClipboard(mockServerBaseUrl)"
/>
</div>
</div>
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("app.status") }}
</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="
existingMockServer?.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
"
>
<span
class="w-2 h-2 rounded-full mr-2"
:class="
existingMockServer?.isActive
? 'bg-green-400'
: 'bg-gray-400'
"
></span>
{{
existingMockServer?.isActive
? t("mockServer.dashboard.active")
: t("mockServer.dashboard.inactive")
}}
</span>
</div>
</div>
</div>
<!-- New Mock Server Form -->
<div v-else class="flex flex-col space-y-4">
<HoppSmartInput
v-model="mockServerName"
v-focus
:label="t('mock_server.mock_server_name')"
input-styles="floating-input"
:disabled="loading"
/>
<div class="flex items-center space-x-4">
<div class="w-48">
<HoppSmartInput
v-model="delayInMsVal"
:label="t('mock_server.delay_ms')"
type="number"
input-styles="floating-input"
:disabled="loading"
/>
</div>
<div class="flex items-center">
<HoppSmartToggle :on="isPublic" @change="isPublic = !isPublic">
{{ t("mock_server.make_public") }}
</HoppSmartToggle>
</div>
</div>
<!-- Display created server info -->
<div v-if="createdServer" class="flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.path_based_url") }}
</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"
>
{{ createdServer.serverUrlPathBased }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="
copyToClipboard(createdServer.serverUrlPathBased || '')
"
/>
</div>
</div>
<!-- Subdomain-based URL (May be null) -->
<div
v-if="createdServer.serverUrlDomainBased"
class="flex flex-col space-y-2"
>
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.subdomain_based_url") }}
</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"
>
{{ createdServer.serverUrlDomainBased }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="copyToClipboard(createdServer.serverUrlDomainBased)"
/>
</div>
<div class="text-xs text-secondaryLight">
<span class="font-medium">{{ t("mock_server.note") }}:</span>
{{ t("mock_server.subdomain_note") }}
</div>
</div>
</div>
</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") }}
</p>
</div>
</div>
</template>
<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"
:label="
existingMockServer?.isActive
? t('mock_server.stop_server')
: t('mock_server.start_server')
"
:loading="loading"
:icon="existingMockServer?.isActive ? IconSquare : IconPlay"
@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
:label="t('mock_server.create_mock_server')"
:loading="loading"
:disabled="!mockServerName.trim() || !effectiveCollectionID"
:icon="IconServer"
@click="createMockServer"
/>
<!-- Close button shown after server creation -->
<HoppButtonSecondary
v-if="showCloseButton"
:label="t('action.close')"
@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 { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
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"
// 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"
const t = useI18n()
const toast = useToast()
const workspaceService = useService(WorkspaceService)
const teamCollectionsService = useService(TeamCollectionsService)
// Modal state
const modalData = useReadonlyStream(showCreateMockServerModal$, {
show: false,
collectionID: undefined,
collectionName: undefined,
})
const mockServers = useReadonlyStream(mockServers$, [])
const collections = useReadonlyStream(restCollections$, [])
const currentWorkspace = computed(() => workspaceService.currentWorkspace.value)
// Get collections based on current workspace
const availableCollections = computed(() => {
if (currentWorkspace.value.type === "team" && currentWorkspace.value.teamID) {
return teamCollectionsService.collections.value || []
}
return collections.value
})
// Component state
const mockServerName = ref("")
const loading = ref(false)
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)
// 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"
)
// Find existing mock server for this collection
const existingMockServer = computed(() => {
if (!collectionID.value) return null
return mockServers.value.find(
(server) => server.collectionID === collectionID.value
)
})
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 hasMockServer = mockServers.value.some(
(server) => server.collectionID === collectionId
)
return {
label: collection.name || collection.title,
value: collectionId,
collection: collection,
hasMockServer: hasMockServer,
disabled: hasMockServer,
}
})
})
// Get the effective collection ID (either pre-selected or user-selected)
const effectiveCollectionID = computed(() => {
return collectionID.value || selectedCollectionID.value
})
// Collection selection handler
const selectCollection = (option: any) => {
// Prevent selection of collections that already have mock servers
if (option.disabled || option.hasMockServer) {
return
}
selectedCollectionID.value = option.value
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,
1000
)
const copyToClipboard = (text: string) => {
copyToClipboardHelper(text)
copyIcon.value = IconCheck
toast.success(t("state.copied_to_clipboard"))
}
// Reset form when modal opens/closes
watch(show, (newShow) => {
if (newShow) {
mockServerName.value = ""
loading.value = false
delayInMsVal.value = "0"
isPublic.value = true
selectedCollectionID.value = ""
selectedCollectionName.value = ""
showCloseButton.value = false
createdServer.value = null
}
})
// Create new mock server
const createMockServer = async () => {
if (!mockServerName.value.trim() || !effectiveCollectionID.value) {
if (!effectiveCollectionID.value) {
toast.error(t("mock_server.select_collection_error"))
}
return
}
loading.value = true
// Determine workspace type and ID based on current workspace
const workspaceType =
currentWorkspace.value.type === "team"
? WorkspaceType.Team
: WorkspaceType.User
const workspaceID =
currentWorkspace.value.type === "team"
? currentWorkspace.value.teamID
: undefined
await pipe(
createMockServerMutation(
mockServerName.value.trim(),
effectiveCollectionID.value,
workspaceType,
workspaceID,
Number(delayInMsVal.value) || 0, // delayInMs
Boolean(isPublic.value) // isPublic
),
TE.match(
(error) => {
console.error("Failed to create mock server:", error)
toast.error(t("error.something_went_wrong"))
loading.value = false
},
(result) => {
console.log("Mock server created:", result)
toast.success(t("mock_server.mock_server_created"))
// Add the new mock server to the store
addMockServer(result)
// Store the created server data and show close button
createdServer.value = result
showCloseButton.value = true
loading.value = false
// Don't close the modal automatically
}
)
)()
}
// Toggle mock server active state
const toggleMockServer = async () => {
if (!existingMockServer.value) return
loading.value = true
const newActiveState = !existingMockServer.value.isActive
await pipe(
updateMockServer(existingMockServer.value.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(existingMockServer.value!.id, {
isActive: newActiveState,
})
loading.value = false
}
)
)()
}
// Close modal function
const closeModal = () => {
showCreateMockServerModal$.next({
show: false,
collectionID: undefined,
collectionName: undefined,
})
}
</script>

View file

@ -0,0 +1,209 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('mock_server.edit_mock_server')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col space-y-6">
<!-- Mock Server Name -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.mock_server_name") }}
</label>
<input
v-model="mockServerName"
type="text"
class="input"
:placeholder="t('mock_server.mock_server_name_placeholder')"
/>
</div>
<!-- Collection Info (Read-only) -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("collection.title") }}
</label>
<div class="text-body text-secondary">
{{ mockServer.collection?.title || t("mock_server.no_collection") }}
</div>
</div>
<!-- Base URL (Read-only) -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.base_url") }}
</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"
>
{{
mockServer.serverUrlDomainBased || mockServer.serverUrlPathBased
}}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
@click="
copyToClipboardHandler(
mockServer.serverUrlDomainBased ||
mockServer.serverUrlPathBased ||
''
)
"
/>
</div>
</div>
<!-- Status Toggle -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.status") }}
</label>
<div class="flex items-center space-x-3">
<HoppSmartToggle :on="isActive" @change="isActive = !isActive" />
<span class="text-sm text-secondaryLight">
{{
isActive
? t("mock_server.server_running")
: t("mock_server.server_stopped")
}}
</span>
</div>
</div>
<!-- Delay Settings -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.delay_ms") }}
</label>
<input
v-model.number="delayInMs"
type="number"
min="0"
class="input"
:placeholder="t('mock_server.delay_placeholder')"
/>
<span class="text-xs text-secondaryLight">
{{ t("mock_server.delay_description") }}
</span>
</div>
<!-- Public Access -->
<div class="flex flex-col space-y-2">
<label class="text-sm font-semibold text-secondaryDark">
{{ t("mock_server.public_access") }}
</label>
<div class="flex items-center space-x-3">
<HoppSmartToggle :on="isPublic" @change="isPublic = !isPublic" />
<span class="text-sm text-secondaryLight">
{{
isPublic
? t("mock_server.public_description")
: t("mock_server.private_description")
}}
</span>
</div>
</div>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.save')"
:loading="loading"
@click="updateMockServer"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
@click="emit('hide-modal')"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "~/composables/toast"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import type { MockServer } from "~/newstore/mockServers"
// Icons
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
interface Props {
show: boolean
mockServer: MockServer
}
const props = defineProps<Props>()
const emit = defineEmits<{
(event: "hide-modal"): void
}>()
const t = useI18n()
const toast = useToast()
const loading = ref(false)
const copyIcon = ref(IconCopy)
// Form data
const mockServerName = ref(props.mockServer.name)
const isActive = ref(props.mockServer.isActive)
const delayInMs = ref(props.mockServer.delayInMs || 0)
const isPublic = ref(props.mockServer.isPublic)
// Watch for prop changes
watch(
() => props.mockServer,
(newMockServer) => {
mockServerName.value = newMockServer.name
isActive.value = newMockServer.isActive
delayInMs.value = newMockServer.delayInMs || 0
isPublic.value = newMockServer.isPublic
},
{ immediate: true }
)
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
}
}
const copyToClipboardHandler = async (text: string) => {
try {
await copyToClipboard(text)
copyIcon.value = IconCheck
toast.success(t("state.copied_to_clipboard"))
setTimeout(() => {
copyIcon.value = IconCopy
}, 1000)
} catch (error) {
toast.error(t("error.copy_failed"))
}
}
</script>

View file

@ -0,0 +1,403 @@
<template>
<div>
<div
class="sticky z-10 flex flex-1 flex-shrink-0 justify-between overflow-x-auto border-b border-dividerLight bg-primary"
>
<HoppButtonSecondary
v-if="!hasNoAccess"
:icon="IconPlus"
:label="t('action.new')"
class="!rounded-none"
@click="openCreateModal"
/>
<HoppButtonSecondary
v-else
v-tippy="{ theme: 'tooltip' }"
disabled
class="!rounded-none"
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('action.new')"
/>
<span class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/mock-servers"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
</span>
</div>
<div class="flex flex-1 flex-col">
<div
v-if="loading"
class="flex flex-1 flex-col items-center justify-center p-4"
>
<HoppSmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-else-if="mockServers.length === 0"
class="flex flex-1 flex-col items-center justify-center p-4"
>
<img
:src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('empty.mock_servers')}`"
class="inline-flex flex-col object-contain object-center w-16 h-16 my-4 opacity-75"
/>
<span class="pb-4 text-center text-secondaryLight">
{{ t("empty.mock_servers") }}
</span>
<HoppButtonSecondary
v-if="!hasNoAccess"
:label="t('mock_server.create_mock_server')"
:icon="IconPlus"
filled
@click="openCreateModal"
/>
</div>
<div v-else class="divide-y divide-dividerLight">
<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">
<component
:is="IconServer"
class="svg-icons"
:class="{
'text-green-500': mockServer.isActive,
'text-secondaryLight': !mockServer.isActive,
}"
/>
</span>
<span
class="flex min-w-0 flex-1 cursor-pointer py-2 pr-2 transition group-hover:text-secondaryDark"
@click="openMockServerLogs(mockServer)"
>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate font-semibold">
{{ mockServer.name }}
</span>
<span class="truncate text-secondaryLight">
{{
mockServer.collection?.title || t("mock_server.no_collection")
}}
</span>
</div>
</span>
<div class="flex">
<HoppButtonSecondary
v-if="
mockServer.serverUrlDomainBased || mockServer.serverUrlPathBased
"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
class="hidden group-hover:inline-flex"
@click="
copyToClipboardHandler(
mockServer.serverUrlDomainBased ||
mockServer.serverUrlPathBased ||
''
)
"
/>
<HoppButtonSecondary
v-if="!hasNoAccess"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.edit')"
:icon="IconEdit"
class="hidden group-hover:inline-flex"
@click="editMockServer(mockServer)"
/>
</div>
<div class="flex items-center">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions?.focus?.()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.s="toggleAction?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
ref="toggleAction"
:icon="mockServer.isActive ? IconStop : IconPlay"
:label="
mockServer.isActive
? t('mock_server.stop_server')
: t('mock_server.start_server')
"
:shortcut="['S']"
@click="
() => {
toggleMockServer(mockServer)
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
deleteMockServer(mockServer)
hide()
}
"
/>
</div>
</template>
</tippy>
</div>
</div>
</div>
</div>
<!-- Modals -->
<MockServerCreateMockServer
v-if="showCreateModal"
:show="showCreateModal"
@hide-modal="showCreateModal = false"
/>
<MockServerEditMockServer
v-if="showEditModal && selectedMockServer"
:show="showEditModal"
:mock-server="selectedMockServer"
@hide-modal="showEditModal = false"
/>
<MockServerLogs
v-if="showLogsModal && selectedMockServer"
:show="showLogsModal"
:mock-server-i-d="selectedMockServer.id"
@close="showLogsModal = false"
/>
<HoppSmartConfirmModal
:show="confirmDeleteMockServer"
:loading-state="loading"
:title="t('confirm.delete_mock_server')"
@hide-modal="confirmDeleteMockServer = false"
@resolve="confirmDelete"
/>
</div>
</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 { 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 {
loadMockServers,
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"
// 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 IconMoreVertical from "~icons/lucide/more-vertical"
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)
const showLogsModal = ref(false)
const selectedMockServer = ref<MockServer | null>(null)
const copyIcon = ref(IconCopy)
const tippyActions = ref<TippyComponent | null>(null)
const toggleAction = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
// Check if user has access (not logged in or no permissions)
const hasNoAccess = computed(() => {
return !platform.auth.getCurrentUser()
})
const editMockServer = (mockServer: MockServer) => {
selectedMockServer.value = mockServer
showEditModal.value = true
}
const openMockServerLogs = (mockServer: MockServer) => {
selectedMockServer.value = mockServer
showLogsModal.value = true
}
const toggleMockServer = async (mockServer: MockServer) => {
loading.value = true
const newActiveState = !mockServer.isActive
await pipe(
updateMockServerMutation(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
},
() => {
// Show success toast
toast.success(
newActiveState
? t("mock_server.mock_server_started")
: t("mock_server.mock_server_stopped")
)
// Update local store state
updateMockServerInStore(mockServer.id, { isActive: newActiveState })
loading.value = false
}
)
)()
}
const confirmDeleteMockServer = ref(false)
const pendingMockServerToDelete = ref<MockServer | null>(null)
// Open confirm modal for deletion
const deleteMockServer = async (mockServer: MockServer) => {
pendingMockServerToDelete.value = mockServer
confirmDeleteMockServer.value = true
}
// Called when the confirm modal is resolved
const confirmDelete = async () => {
const mockServer = pendingMockServerToDelete.value
if (!mockServer) return
loading.value = true
// hide the modal
confirmDeleteMockServer.value = false
await pipe(
deleteMockServerMutation(mockServer.id),
TE.match(
(error) => {
console.error("Failed to delete mock server:", error)
toast.error(t("error.something_went_wrong"))
loading.value = false
pendingMockServerToDelete.value = null
},
(result) => {
if (result) {
// Remove from local store
deleteMockServerInStore(mockServer.id)
// If the deleted server was selected, clear selection and close logs modal
if (selectedMockServer.value?.id === mockServer.id) {
selectedMockServer.value = null
showLogsModal.value = false
showEditModal.value = false
}
toast.success(t("state.deleted"))
} else {
toast.error(t("error.something_went_wrong"))
}
loading.value = false
pendingMockServerToDelete.value = null
}
)
)()
}
const copyToClipboardHandler = async (text: string) => {
try {
await copyToClipboard(text)
copyIcon.value = IconCheck
// Show which URL was copied
toast.success(`${t("mock_server.url_copied")}: ${text}`)
setTimeout(() => {
copyIcon.value = IconCopy
}, 1000)
} catch (error) {
toast.error(t("error.copy_failed"))
}
}
const openCreateModal = () => {
// Open the create modal without a pre-selected collection
showCreateMockServerModal$.next({
show: true,
collectionID: undefined,
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

@ -0,0 +1,162 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('mock_server.logs_title')"
@close="close"
>
<template #body>
<div class="p-4">
<div v-if="loading" class="flex justify-center py-8">
<HoppSmartSpinner />
</div>
<div v-else>
<div v-if="logs.length === 0" class="text-center text-secondary">
{{ t("mock_server.no_logs") }}
</div>
<div
v-for="log in logs"
:key="log.id"
class="mb-4 p-3 border rounded"
>
<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>
</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>
<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>
</div>
</template>
<template #footer>
<div class="flex justify-end">
<HoppButtonSecondary @click="close">{{
t("action.close")
}}</HoppButtonSecondary>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { useI18n } from "~/composables/i18n"
import {
getMockServerLogs,
deleteMockServerLog,
} from "~/helpers/backend/queries/MockServerLogs"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { useToast } from "~/composables/toast"
const props = defineProps<{ show: boolean; mockServerID: string }>()
const emit = defineEmits<{ (e: "close"): void }>()
const t = useI18n()
const toast = useToast()
const loading = ref(false)
const logs = ref<any[]>([])
const fetchLogs = async () => {
loading.value = true
await pipe(
getMockServerLogs(props.mockServerID),
TE.match(
(err) => {
console.error("Failed to load logs", err)
toast.error(t("error.something_went_wrong"))
loading.value = false
},
(res) => {
logs.value = res
loading.value = false
}
)
)()
}
onMounted(() => {
if (props.show) fetchLogs()
})
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 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)
}
}
</script>

View file

@ -0,0 +1,70 @@
import { computed } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { mockServers$ } from "~/newstore/mockServers"
import type { MockServer } from "~/newstore/mockServers"
/**
* Composable to get mock server status for collections
*/
export function useMockServerStatus() {
const mockServers = useReadonlyStream(mockServers$, [])
/**
* Get mock server for a specific collection
*/
const getMockServerForCollection = (
collectionId: string
): MockServer | null => {
return (
mockServers.value.find(
(server) =>
server.collection?.id === collectionId ||
server.collectionID === collectionId
) || null
)
}
/**
* Check if a collection has an active mock server
*/
const hasActiveMockServer = (collectionId: string): boolean => {
const mockServer = getMockServerForCollection(collectionId)
return mockServer?.isActive === true
}
/**
* Check if a collection has any mock server (active or inactive)
*/
const hasMockServer = (collectionId: string): boolean => {
return getMockServerForCollection(collectionId) !== null
}
/**
* Get mock server status for a collection
*/
const getMockServerStatus = (collectionId: string) => {
const mockServer = getMockServerForCollection(collectionId)
if (!mockServer) {
return {
exists: false,
isActive: false,
mockServer: null,
}
}
return {
exists: true,
isActive: mockServer.isActive,
mockServer,
}
}
return {
mockServers: computed(() => mockServers.value),
getMockServerForCollection,
hasActiveMockServer,
hasMockServer,
getMockServerStatus,
}
}

View file

@ -0,0 +1,23 @@
mutation CreateMockServer($input: CreateMockServerInput!) {
createMockServer(input: $input) {
id
name
subdomain
serverUrlPathBased
serverUrlDomainBased
workspaceType
workspaceID
delayInMs
isPublic
isActive
createdOn
updatedOn
creator {
uid
}
collection {
id
title
}
}
}

View file

@ -0,0 +1,3 @@
mutation DeleteMockServer($id: ID!) {
deleteMockServer(id: $id)
}

View file

@ -0,0 +1,3 @@
mutation DeleteMockServerLog($logID: ID!) {
deleteMockServerLog(logID: $logID)
}

View file

@ -0,0 +1,23 @@
mutation UpdateMockServer($id: ID!, $input: UpdateMockServerInput!) {
updateMockServer(id: $id, input: $input) {
id
name
subdomain
serverUrlPathBased
serverUrlDomainBased
workspaceType
workspaceID
delayInMs
isPublic
isActive
createdOn
updatedOn
creator {
uid
}
collection {
id
title
}
}
}

View file

@ -0,0 +1,23 @@
query GetMockServer($id: ID!) {
mockServer(id: $id) {
id
name
subdomain
serverUrlPathBased
serverUrlDomainBased
workspaceType
workspaceID
delayInMs
isPublic
isActive
createdOn
updatedOn
creator {
uid
}
collection {
id
title
}
}
}

View file

@ -0,0 +1,18 @@
query GetMockServerLogs($mockServerID: ID!, $skip: Int, $take: Int) {
mockServerLogs(mockServerID: $mockServerID, skip: $skip, take: $take) {
id
mockServerID
requestMethod
requestPath
requestHeaders
requestBody
requestQuery
responseStatus
responseHeaders
responseBody
responseTime
ipAddress
userAgent
executedAt
}
}

View file

@ -0,0 +1,23 @@
query GetMyMockServers($skip: Int, $take: Int) {
myMockServers(skip: $skip, take: $take) {
id
name
subdomain
serverUrlPathBased
serverUrlDomainBased
workspaceType
workspaceID
delayInMs
isPublic
isActive
createdOn
updatedOn
creator {
uid
}
collection {
id
title
}
}
}

View file

@ -0,0 +1,23 @@
query GetTeamMockServers($teamID: ID!, $skip: Int, $take: Int) {
teamMockServers(teamID: $teamID, skip: $skip, take: $take) {
id
name
subdomain
serverUrlPathBased
serverUrlDomainBased
workspaceType
workspaceID
delayInMs
isPublic
isActive
createdOn
updatedOn
creator {
uid
}
collection {
id
title
}
}
}

View file

@ -0,0 +1,185 @@
import * as TE from "fp-ts/TaskEither"
import { client } from "../GQLClient"
import {
CreateMockServerDocument,
UpdateMockServerDocument,
DeleteMockServerDocument,
GetTeamMockServersDocument,
WorkspaceType,
} from "../graphql"
// Types for mock server
export type MockServer = {
id: string
name: string
subdomain: string
serverUrlPathBased?: string
serverUrlDomainBased?: string | null
workspaceType: WorkspaceType
workspaceID?: string | null
delayInMs?: number
isPublic: boolean
isActive: boolean
createdOn: Date
updatedOn: Date
creator?: {
uid: string
}
collection?: {
id: string
title: string
requests?: any[]
}
// Legacy fields for backward compatibility
userUid?: string
collectionID?: string
}
type CreateMockServerError =
| "mock_server/invalid_collection_id"
| "mock_server/name_too_short"
| "mock_server/limit_exceeded"
| "mock_server/already_exists"
type UpdateMockServerError =
| "mock_server/not_found"
| "mock_server/access_denied"
type DeleteMockServerError =
| "mock_server/not_found"
| "mock_server/access_denied"
export const createMockServer = (
name: string,
collectionID: string,
workspaceType: WorkspaceType = WorkspaceType.User,
workspaceID?: string,
delayInMs: number = 0,
isPublic: boolean = true
) =>
TE.tryCatch(
async () => {
const result = await client
.value!.mutation(CreateMockServerDocument, {
input: {
name,
collectionID,
workspaceType,
workspaceID,
delayInMs,
isPublic,
},
})
.toPromise()
if (result.error) {
throw new Error(result.error.message || "Failed to create mock server")
}
if (!result.data) {
throw new Error("No data returned from create mock server mutation")
}
const data = result.data.createMockServer
// Map the GraphQL response to frontend format
return {
...data,
userUid: data.creator?.uid || "", // Legacy field
collectionID: data.collection?.id || collectionID, // Legacy field
} as MockServer
},
(error) => (error as Error).message as CreateMockServerError
)
export const updateMockServer = (
id: string,
input: {
name?: string
isActive?: boolean
delayInMs?: number
isPublic?: boolean
}
) =>
TE.tryCatch(
async () => {
const result = await client
.value!.mutation(UpdateMockServerDocument, {
id,
input,
})
.toPromise()
if (result.error) {
throw new Error(result.error.message || "Failed to update mock server")
}
if (!result.data) {
throw new Error("No data returned from update mock server mutation")
}
const data = result.data.updateMockServer
// Map the GraphQL response to frontend format
return {
...data,
userUid: data.creator?.uid || "", // Legacy field
collectionID: data.collection?.id || "", // Legacy field
} as MockServer
},
(error) => (error as Error).message as UpdateMockServerError
)
export const deleteMockServer = (id: string) =>
TE.tryCatch(
async () => {
const result = await client
.value!.mutation(DeleteMockServerDocument, { id })
.toPromise()
if (result.error) {
throw new Error(result.error.message || "Failed to delete mock server")
}
if (!result.data) {
throw new Error("No data returned from delete mock server mutation")
}
return result.data.deleteMockServer as boolean
},
(error) => (error as Error).message as DeleteMockServerError
)
export const getTeamMockServers = (
teamID: string,
skip?: number,
take?: number
) =>
TE.tryCatch(
async () => {
const result = await client
.value!.query(GetTeamMockServersDocument, {
teamID,
skip,
take,
})
.toPromise()
if (result.error) {
throw new Error(
result.error.message || "Failed to get team mock servers"
)
}
if (!result.data) {
throw new Error("No data returned from get team mock servers query")
}
const data = result.data.teamMockServers
// Map the GraphQL response to frontend format
return data.map((mockServer: any) => ({
...mockServer,
userUid: mockServer.creator?.uid || "", // Legacy field
collectionID: mockServer.collection?.id || "", // Legacy field
})) as MockServer[]
},
(error) => (error as Error).message as CreateMockServerError
)

View file

@ -0,0 +1,96 @@
import * as TE from "fp-ts/TaskEither"
import * as E from "fp-ts/Either"
import { runGQLQuery } from "../GQLClient"
import {
GetMyMockServersDocument,
GetTeamMockServersDocument,
GetMockServerDocument,
type GetMyMockServersQuery,
type GetTeamMockServersQuery,
type GetMockServerQuery,
} from "../graphql"
type GetMyMockServersError = "user/not_authenticated"
type GetTeamMockServersError = "team/not_found" | "team/access_denied"
type GetMockServerError = "mock_server/not_found" | "mock_server/access_denied"
export const getMyMockServers = (skip?: number, take?: number) =>
TE.tryCatch(
async () => {
const result = await runGQLQuery({
query: GetMyMockServersDocument,
variables: { skip, take },
})
if (E.isLeft(result)) {
throw result.left
}
const data = result.right as GetMyMockServersQuery
// Map the GraphQL response to frontend format
return data.myMockServers.map((mockServer) => ({
...mockServer,
creator: mockServer.creator
? { uid: mockServer.creator.uid }
: undefined,
userUid: mockServer.creator?.uid || "", // Legacy field
collectionID: mockServer.collection?.id || "", // Legacy field
}))
},
(error) => error as GetMyMockServersError
)
export const getTeamMockServers = (
teamID: string,
skip?: number,
take?: number
) =>
TE.tryCatch(
async () => {
const result = await runGQLQuery({
query: GetTeamMockServersDocument,
variables: { teamID, skip, take },
})
if (E.isLeft(result)) {
throw result.left
}
const data = result.right as GetTeamMockServersQuery
// Map the GraphQL response to frontend format
return data.teamMockServers.map((mockServer) => ({
...mockServer,
creator: mockServer.creator
? { uid: mockServer.creator.uid }
: undefined,
userUid: mockServer.creator?.uid || "", // Legacy field
collectionID: mockServer.collection?.id || "", // Legacy field
}))
},
(error) => error as GetTeamMockServersError
)
export const getMockServer = (id: string) =>
TE.tryCatch(
async () => {
const result = await runGQLQuery({
query: GetMockServerDocument,
variables: { id },
})
if (E.isLeft(result)) {
throw result.left
}
const data = result.right as GetMockServerQuery
// Map the GraphQL response to frontend format
return {
...data.mockServer,
userUid: data.mockServer.creator?.uid || "", // Legacy field
collectionID: data.mockServer.collection?.id || "", // Legacy field
}
},
(error) => error as GetMockServerError
)

View file

@ -0,0 +1,55 @@
import * as TE from "fp-ts/TaskEither"
import { client } from "../GQLClient"
import {
GetMockServerLogsDocument,
GetMockServerLogsQuery,
GetMockServerLogsQueryVariables,
DeleteMockServerLogDocument,
DeleteMockServerLogMutation,
DeleteMockServerLogMutationVariables,
} from "../graphql"
export const getMockServerLogs = (
mockServerID: string,
skip?: number,
take?: number
) =>
TE.tryCatch(
async () => {
const result = await client
.value!.query<
GetMockServerLogsQuery,
GetMockServerLogsQueryVariables
>(GetMockServerLogsDocument, { mockServerID, skip, take })
.toPromise()
if (result.error)
throw new Error(
result.error.message || "Failed to fetch mock server logs"
)
if (!result.data) throw new Error("No data returned from mockServerLogs")
return result.data.mockServerLogs
},
(e) => (e as Error).message
)
export const deleteMockServerLog = (logID: string) =>
TE.tryCatch(
async () => {
const result = await client
.value!.mutation<
DeleteMockServerLogMutation,
DeleteMockServerLogMutationVariables
>(DeleteMockServerLogDocument, { logID })
.toPromise()
if (result.error)
throw new Error(
result.error.message || "Failed to delete mock server log"
)
if (!result.data)
throw new Error("No data returned from deleteMockServerLog")
return result.data.deleteMockServerLog as boolean
},
(e) => (e as Error).message
)

View file

@ -0,0 +1,221 @@
import { pluck } from "rxjs/operators"
import { BehaviorSubject } from "rxjs"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
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"
export type WorkspaceType = "USER" | "TEAM"
export type MockServer = {
id: string
name: string
subdomain: string
serverUrlPathBased?: string
serverUrlDomainBased?: string | null
workspaceType: WorkspaceType
workspaceID?: string | null
delayInMs?: number
isPublic: boolean
isActive: boolean
createdOn: Date
updatedOn: Date
creator?: {
uid: string
}
collection?: {
id: string
title: string
requests?: any[]
}
// Legacy fields for backward compatibility
userUid?: string
collectionID?: string
}
export type CreateMockServerInput = {
name: string
collectionID: string
workspaceType?: WorkspaceType
workspaceID?: string
delayInMs?: number
isPublic?: boolean
}
export type UpdateMockServerInput = {
name?: string
isActive?: boolean
delayInMs?: number
isPublic?: boolean
}
export type CreateMockServerModalData = {
show: boolean
collectionID?: string
collectionName?: string
}
const defaultMockServerState = {
mockServers: [] as MockServer[],
}
type MockServerStoreType = typeof defaultMockServerState
const mockServerDispatchers = defineDispatchers({
setMockServers(
_: MockServerStoreType,
{ mockServers }: { mockServers: MockServer[] }
) {
return {
mockServers,
}
},
addMockServer(
{ mockServers }: MockServerStoreType,
{ mockServer }: { mockServer: MockServer }
) {
return {
mockServers: [...mockServers, mockServer],
}
},
updateMockServer(
{ mockServers }: MockServerStoreType,
{ id, updates }: { id: string; updates: Partial<MockServer> }
) {
return {
mockServers: mockServers.map((server) =>
server.id === id ? { ...server, ...updates } : server
),
}
},
deleteMockServer(
{ mockServers }: MockServerStoreType,
{ id }: { id: string }
) {
return {
mockServers: mockServers.filter((server) => server.id !== id),
}
},
})
export const mockServerStore = new DispatchingStore(
defaultMockServerState,
mockServerDispatchers
)
export const mockServers$ = mockServerStore.subject$.pipe(pluck("mockServers"))
export function setMockServers(mockServers: MockServer[]) {
mockServerStore.dispatch({
dispatcher: "setMockServers",
payload: { mockServers },
})
}
export function addMockServer(mockServer: MockServer) {
mockServerStore.dispatch({
dispatcher: "addMockServer",
payload: { mockServer },
})
}
export function updateMockServer(id: string, updates: Partial<MockServer>) {
mockServerStore.dispatch({
dispatcher: "updateMockServer",
payload: { id, updates },
})
}
export function deleteMockServer(id: string) {
mockServerStore.dispatch({
dispatcher: "deleteMockServer",
payload: { id },
})
}
// Modal state management
const defaultCreateMockServerModalState: CreateMockServerModalData = {
show: false,
collectionID: undefined,
collectionName: undefined,
}
export const showCreateMockServerModal$ = new BehaviorSubject(
defaultCreateMockServerModalState
)
// Load mock servers from backend (workspace-aware)
export function loadMockServers(skip?: number, take?: number) {
try {
const workspaceService = getService(WorkspaceService)
const currentWorkspace = workspaceService.currentWorkspace.value
if (currentWorkspace.type === "team" && currentWorkspace.teamID) {
return loadTeamMockServers(currentWorkspace.teamID, skip, take)
}
return pipe(
getMyMockServers(skip, take),
TE.match(
(error) => {
console.error("Failed to load mock servers:", error)
},
(mockServers) => {
setMockServers(mockServers)
}
)
)()
} catch (error) {
// Fallback to user mock servers if workspace service is not available
return pipe(
getMyMockServers(skip, take),
TE.match(
(error) => {
console.error("Failed to load mock servers:", error)
},
(mockServers) => {
setMockServers(mockServers)
}
)
)()
}
}
// Load team mock servers from backend
export function loadTeamMockServers(
teamID: string,
skip?: number,
take?: number
) {
return pipe(
getTeamMockServers(teamID, skip, take),
TE.match(
(error) => {
console.error("Failed to load team mock servers:", error)
},
(mockServers) => {
setMockServers(mockServers)
}
)
)()
}
// Load mock servers based on workspace context
export function loadMockServersForWorkspace(
workspaceType: "personal" | "team",
teamID?: string,
skip?: number,
take?: number
) {
if (workspaceType === "team" && teamID) {
return loadTeamMockServers(teamID, skip, take)
}
return loadMockServers(skip, take)
}

View file

@ -51,6 +51,7 @@ import {
updateRESTCollectionOrder,
updateRESTRequestOrder,
} from "@hoppscotch/common/newstore/collections"
import { loadMockServers } from "@hoppscotch/common/newstore/mockServers"
import {
GQLHeader,
HoppCollection,
@ -74,6 +75,7 @@ function initCollectionsSync() {
gqlCollectionsSyncer.startStoreSync()
// TODO: fix collection schema transformation on backend maybe?
loadUserCollections("REST")
loadUserCollections("GQL")
@ -82,6 +84,7 @@ function initCollectionsSync() {
if (user) {
loadUserCollections("REST")
loadUserCollections("GQL")
loadMockServers()
}
})

View file

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

View file

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

View file

@ -101,7 +101,7 @@
"rate_limit": {
"description": "Configure rate limiting for your Hoppscotch instance",
"rate_limit_max": "Maximum Requests",
"rate_limit_ttl": "Time to Leave (in seconds)",
"rate_limit_ttl": "Time to Leave (in milliseconds)",
"title": "Rate Limit Configurations",
"update_failure": "Failed to update rate limit configurations!!",
"input_validation_error": "Please enter valid values for rate limit configurations"
@ -129,6 +129,14 @@
"reset": "Reset Configurations"
},
"title": "Configurations",
"mock_server": {
"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"
},
"update_failure": "Failed to update server configurations"
},
"data_sharing": {

View file

@ -3,7 +3,7 @@
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {};
export {}
/* prettier-ignore */
declare module 'vue' {
@ -58,10 +58,12 @@ declare module 'vue' {
SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default']
SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default']
SettingsHistoryConfiguration: typeof import('./components/settings/HistoryConfiguration.vue')['default']
SettingsMockServerConfig: typeof import('./components/settings/MockServerConfig.vue')['default']
SettingsOAuthProviderConfigurations: typeof import('./components/settings/OAuthProviderConfigurations.vue')['default']
SettingsRateLimit: typeof import('./components/settings/RateLimit.vue')['default']
SettingsReset: typeof import('./components/settings/Reset.vue')['default']
SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default']
SettingsSettingsMockServer: typeof import('./components/settings/SettingsMockServer.vue')['default']
SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default']
SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']

View file

@ -0,0 +1,85 @@
<template>
<div class="grid md:grid-cols-3 gap-8 md:gap-4 pt-8">
<div class="md:col-span-1">
<h3 class="heading">{{ t('configs.mock_server.title') }}</h3>
<p class="my-1 text-secondaryLight">
{{ t('configs.mock_server.description') }}
</p>
</div>
<div class="sm:px-8 md:col-span-2">
<h4 class="font-semibold text-secondaryDark">
{{ t('configs.mock_server.title') }}
</h4>
<div class="space-y-4 py-4">
<div>
<label class="block text-sm font-medium text-secondaryDark mb-1">
{{ t('configs.mock_server.wildcard_domain') }}
</label>
<input
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
"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core';
import { computed } from 'vue';
import { useI18n } from '~/composables/i18n';
import { ServerConfigs } from '~/helpers/configs';
const t = useI18n();
const props = defineProps<{
config: ServerConfigs;
}>();
const emit = defineEmits<{
(e: 'update:config', v: ServerConfigs): void;
}>();
const workingConfigs = useVModel(props, 'config', emit);
const mockFields = computed({
get() {
return (
workingConfigs.value.mockServerConfigs?.fields ?? {
mock_server_wildcard_domain: '',
allow_secure_cookies: false,
}
);
},
set(v) {
if (!workingConfigs.value.mockServerConfigs) {
workingConfigs.value.mockServerConfigs = {
name: 'mock_server',
fields: v,
};
} else workingConfigs.value.mockServerConfigs.fields = v as any;
},
});
</script>

View file

@ -26,6 +26,7 @@ import {
GOOGLE_CONFIGS,
MAIL_CONFIGS,
MICROSOFT_CONFIGS,
MOCK_SERVER_CONFIGS,
ServerConfigs,
UpdatedConfigs,
} from '~/helpers/configs';
@ -176,6 +177,16 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
rate_limit_max: getFieldValue(InfraConfigEnum.RateLimitMax),
},
},
mockServerConfigs: {
name: 'mock_server',
fields: {
mock_server_wildcard_domain: getFieldValue(
InfraConfigEnum.MockServerWildcardDomain
),
allow_secure_cookies:
getFieldValue(InfraConfigEnum.AllowSecureCookies) === 'true',
},
},
};
// Cloning the current configs to working configs
@ -343,6 +354,11 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
enabled: isCustomMailConfigEnabled,
fields: customMailConfigFields,
},
{
config: MOCK_SERVER_CONFIGS,
enabled: true,
fields: updatedConfigs?.mockServerConfigs?.fields ?? {},
},
];
const transformedConfigs: UpdatedConfigs[] = [];

View file

@ -87,6 +87,13 @@ export type ServerConfigs = {
rate_limit_max: string;
};
};
mockServerConfigs?: {
name: string;
fields: {
mock_server_wildcard_domain: string;
allow_secure_cookies: boolean;
};
};
};
export type UpdatedConfigs = {
@ -269,6 +276,17 @@ export const TOKEN_VALIDATION_CONFIGS: Config[] = [
},
];
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 = [
GOOGLE_CONFIGS,
MICROSOFT_CONFIGS,
@ -279,4 +297,5 @@ export const ALL_CONFIGS = [
HISTORY_STORE_CONFIG,
RATE_LIMIT_CONFIGS,
TOKEN_VALIDATION_CONFIGS,
MOCK_SERVER_CONFIGS,
];

View file

@ -37,9 +37,13 @@
<HoppSmartTab id="miscellaneous" :label="t('configs.tabs.miscellaneous')">
<div class="pb-8 px-4 flex flex-col space-y-8 divide-y divide-divider">
<SettingsDataSharing v-model:config="workingConfigs" />
<SettingsMockServer v-model:config="workingConfigs" />
<SettingsReset />
</div>
</HoppSmartTab>
<HoppSmartTab id="mock" :label="t('configs.mock_server.title')">
<SettingsMockServerConfig v-model:config="workingConfigs" />
</HoppSmartTab>
</HoppSmartTabs>
</div>