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:
parent
dd8744f292
commit
3acc0ec9b6
59 changed files with 6249 additions and 69 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
294
packages/hoppscotch-backend/src/mock-server/mock-server.model.ts
Normal file
294
packages/hoppscotch-backend/src/mock-server/mock-server.model.ts
Normal 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',
|
||||
});
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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
1043
packages/hoppscotch-backend/src/mock-server/mock-server.service.ts
Normal file
1043
packages/hoppscotch-backend/src/mock-server/mock-server.service.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
4
packages/hoppscotch-backend/src/types/WorkspaceTypes.ts
Normal file
4
packages/hoppscotch-backend/src/types/WorkspaceTypes.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum WorkspaceType {
|
||||
USER = 'USER',
|
||||
TEAM = 'TEAM',
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
70
packages/hoppscotch-common/src/composables/mockServer.ts
Normal file
70
packages/hoppscotch-common/src/composables/mockServer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mutation DeleteMockServer($id: ID!) {
|
||||
deleteMockServer(id: $id)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mutation DeleteMockServerLog($logID: ID!) {
|
||||
deleteMockServerLog(logID: $logID)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
221
packages/hoppscotch-common/src/newstore/mockServers.ts
Normal file
221
packages/hoppscotch-common/src/newstore/mockServers.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue