From 3acc0ec9b681e289515a28cfc2280d917758c76a Mon Sep 17 00:00:00 2001 From: Mir Arif Hasan Date: Mon, 27 Oct 2025 23:33:22 +0600 Subject: [PATCH] feat: mock server (#5482) Co-authored-by: Anwarul Islam Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com> --- aio-multiport-setup.Caddyfile | 13 +- aio-subpath-access.Caddyfile | 13 +- packages/hoppscotch-backend/backend.Caddyfile | 13 +- .../20251016080714_mock_server/migration.sql | 142 ++ .../hoppscotch-backend/prisma/schema.prisma | 186 ++- packages/hoppscotch-backend/src/app.module.ts | 2 + packages/hoppscotch-backend/src/errors.ts | 49 + packages/hoppscotch-backend/src/gql-schema.ts | 2 + .../src/infra-config/helper.ts | 5 + .../src/infra-config/infra-config.service.ts | 11 + .../src/mock-server/mock-request.guard.ts | 274 ++++ .../mock-server-analytics.service.ts | 43 + .../mock-server-logging.interceptor.ts | 169 +++ .../src/mock-server/mock-server.controller.ts | 124 ++ .../src/mock-server/mock-server.model.ts | 294 +++++ .../src/mock-server/mock-server.module.ts | 22 + .../src/mock-server/mock-server.resolver.ts | 222 ++++ .../mock-server/mock-server.service.spec.ts | 1157 +++++++++++++++++ .../src/mock-server/mock-server.service.ts | 1043 +++++++++++++++ .../orchestration/sort/sort.service.spec.ts | 2 +- .../team-collection.service.ts | 2 +- .../team-request/team-request.service.spec.ts | 1 + .../src/types/InfraConfig.ts | 2 + .../src/types/WorkspaceTypes.ts | 4 + .../user-request/user-request.service.spec.ts | 8 + .../src/user/user.service.ts | 2 +- packages/hoppscotch-common/locales/en.json | 110 +- .../hoppscotch-common/src/components.d.ts | 6 + .../src/components/collections/Collection.vue | 70 + .../components/collections/MyCollections.vue | 14 + .../collections/TeamCollections.vue | 14 + .../src/components/collections/index.vue | 49 + .../src/components/http/Sidebar.vue | 15 + .../mockServer/CreateMockServer.vue | 550 ++++++++ .../components/mockServer/EditMockServer.vue | 209 +++ .../mockServer/MockServerDashboard.vue | 403 ++++++ .../components/mockServer/MockServerLogs.vue | 162 +++ .../src/composables/mockServer.ts | 70 + .../gql/mutations/CreateMockServer.graphql | 23 + .../gql/mutations/DeleteMockServer.graphql | 3 + .../gql/mutations/DeleteMockServerLog.graphql | 3 + .../gql/mutations/UpdateMockServer.graphql | 23 + .../backend/gql/queries/GetMockServer.graphql | 23 + .../gql/queries/GetMockServerLogs.graphql | 18 + .../gql/queries/GetMyMockServers.graphql | 23 + .../gql/queries/GetTeamMockServers.graphql | 23 + .../helpers/backend/mutations/MockServer.ts | 185 +++ .../src/helpers/backend/queries/MockServer.ts | 96 ++ .../helpers/backend/queries/MockServerLogs.ts | 55 + .../src/newstore/mockServers.ts | 221 ++++ .../collections/collections.platform.ts | 3 + .../src/platform/collections/desktop/index.ts | 2 + .../src/platform/collections/web/index.ts | 2 + packages/hoppscotch-sh-admin/locales/en.json | 10 +- .../hoppscotch-sh-admin/src/components.d.ts | 4 +- .../components/settings/MockServerConfig.vue | 85 ++ .../src/composables/useConfigHandler.ts | 16 + .../src/helpers/configs.ts | 19 + .../src/pages/settings.vue | 4 + 59 files changed, 6249 insertions(+), 69 deletions(-) create mode 100644 packages/hoppscotch-backend/prisma/migrations/20251016080714_mock_server/migration.sql create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-server-analytics.service.ts create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-server-logging.interceptor.ts create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-server.model.ts create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-server.module.ts create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts create mode 100644 packages/hoppscotch-backend/src/mock-server/mock-server.service.ts create mode 100644 packages/hoppscotch-backend/src/types/WorkspaceTypes.ts create mode 100644 packages/hoppscotch-common/src/components/mockServer/CreateMockServer.vue create mode 100644 packages/hoppscotch-common/src/components/mockServer/EditMockServer.vue create mode 100644 packages/hoppscotch-common/src/components/mockServer/MockServerDashboard.vue create mode 100644 packages/hoppscotch-common/src/components/mockServer/MockServerLogs.vue create mode 100644 packages/hoppscotch-common/src/composables/mockServer.ts create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateMockServer.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServer.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServerLog.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateMockServer.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServer.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServerLogs.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyMockServers.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMockServers.graphql create mode 100644 packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts create mode 100644 packages/hoppscotch-common/src/helpers/backend/queries/MockServer.ts create mode 100644 packages/hoppscotch-common/src/helpers/backend/queries/MockServerLogs.ts create mode 100644 packages/hoppscotch-common/src/newstore/mockServers.ts create mode 100644 packages/hoppscotch-sh-admin/src/components/settings/MockServerConfig.vue diff --git a/aio-multiport-setup.Caddyfile b/aio-multiport-setup.Caddyfile index 6140f464..6a00ebba 100644 --- a/aio-multiport-setup.Caddyfile +++ b/aio-multiport-setup.Caddyfile @@ -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 + } } diff --git a/aio-subpath-access.Caddyfile b/aio-subpath-access.Caddyfile index 10b8bf55..4bf8e3b7 100644 --- a/aio-subpath-access.Caddyfile +++ b/aio-subpath-access.Caddyfile @@ -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 diff --git a/packages/hoppscotch-backend/backend.Caddyfile b/packages/hoppscotch-backend/backend.Caddyfile index 523516b8..4c6599c4 100644 --- a/packages/hoppscotch-backend/backend.Caddyfile +++ b/packages/hoppscotch-backend/backend.Caddyfile @@ -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 + } } diff --git a/packages/hoppscotch-backend/prisma/migrations/20251016080714_mock_server/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20251016080714_mock_server/migration.sql new file mode 100644 index 00000000..b0caa4c5 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20251016080714_mock_server/migration.sql @@ -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"); \ No newline at end of file diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index dfc8df01..fb31d5aa 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -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 +} diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index 18859d8f..aeaea85b 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -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, diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 817f1afe..d5efd947 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -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'; diff --git a/packages/hoppscotch-backend/src/gql-schema.ts b/packages/hoppscotch-backend/src/gql-schema.ts index fc43384b..34e613e2 100644 --- a/packages/hoppscotch-backend/src/gql-schema.ts +++ b/packages/hoppscotch-backend/src/gql-schema.ts @@ -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, ]; /** diff --git a/packages/hoppscotch-backend/src/infra-config/helper.ts b/packages/hoppscotch-backend/src/infra-config/helper.ts index daf0c9f8..496cbb66 100644 --- a/packages/hoppscotch-backend/src/infra-config/helper.ts +++ b/packages/hoppscotch-backend/src/infra-config/helper.ts @@ -302,6 +302,11 @@ export async function getDefaultInfraConfigs(): Promise { value: 'true', isEncrypted: false, }, + { + name: InfraConfigEnum.MOCK_SERVER_WILDCARD_DOMAIN, + value: null, + isEncrypted: false, + }, ]; return infraConfigDefaultObjs; diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts index 8d270544..dc0dc4b6 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts @@ -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: diff --git a/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts b/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts new file mode 100644 index 00000000..2ca1699c --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + + // 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 { + // 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; + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server-analytics.service.ts b/packages/hoppscotch-backend/src/mock-server/mock-server-analytics.service.ts new file mode 100644 index 00000000..92ac076f --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server-analytics.service.ts @@ -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 { + 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); + } + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server-logging.interceptor.ts b/packages/hoppscotch-backend/src/mock-server/mock-server-logging.interceptor.ts new file mode 100644 index 00000000..275781a4 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server-logging.interceptor.ts @@ -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 { + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + const response = httpContext.getResponse(); + + // 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 { + const headers: Record = {}; + 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 | undefined { + const queryParams = request.query as Record; + return Object.keys(queryParams).length > 0 ? queryParams : undefined; + } + + /** + * Extract response headers as a plain object + */ + private extractResponseHeaders(response: Response): Record { + const headers: Record = {}; + 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; + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts new file mode 100644 index 00000000..66a4e810 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts @@ -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; + + // Extract request headers (convert to lowercase for case-insensitive matching) + const requestHeaders: Record = {}; + 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', + }); + } + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts new file mode 100644 index 00000000..fc10743e --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts @@ -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', +}); diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts new file mode 100644 index 00000000..c288d2c9 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts @@ -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 {} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts new file mode 100644 index 00000000..93893e59 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const result = await this.mockServerService.deleteMockServerLog( + logID, + user.uid, + ); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } +} diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts new file mode 100644 index 00000000..45a85afc --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts @@ -0,0 +1,1157 @@ +import { MockServerService } from './mock-server.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; +import { MockServerAnalyticsService } from './mock-server-analytics.service'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import * as E from 'fp-ts/Either'; +import { + MOCK_SERVER_NOT_FOUND, + MOCK_SERVER_INVALID_COLLECTION, + TEAM_INVALID_ID, + MOCK_SERVER_LOG_NOT_FOUND, +} from '../errors'; +import { + MockServer as dbMockServer, + TeamAccessRole, + MockServerAction, + UserCollection, + TeamCollection, + UserRequest, +} from '@prisma/client'; +import { WorkspaceType } from '../types/WorkspaceTypes'; +import { User } from '../user/user.model'; +import { + CreateMockServerInput, + UpdateMockServerInput, +} from './mock-server.model'; + +const mockPrisma = mockDeep(); +const mockAnalyticsService = mockDeep(); +const mockConfigService = mockDeep(); + +const mockServerService = new MockServerService( + mockAnalyticsService, + mockPrisma, + mockConfigService, +); + +beforeEach(() => { + mockReset(mockPrisma); + mockReset(mockAnalyticsService); + mockReset(mockConfigService); + + // Default config values + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'VITE_BACKEND_API_URL') return 'http://localhost:3170/v1'; + if (key === 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN') return '*.mock.hopp.io'; + if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'false'; + return undefined; + }); +}); + +const currentTime = new Date(); + +const user: User = { + uid: 'user123', + displayName: 'Test User', + email: 'test@example.com', + photoURL: null, + isAdmin: false, + currentGQLSession: '{}', + currentRESTSession: '{}', + createdOn: currentTime, + lastLoggedOn: currentTime, + lastActiveOn: currentTime, +}; + +const dbMockServer: dbMockServer = { + id: 'mock123', + name: 'Test Mock Server', + subdomain: 'test-subdomain', + creatorUid: user.uid, + collectionID: 'coll123', + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + delayInMs: 0, + isPublic: true, + isActive: true, + hitCount: 0, + lastHitAt: null, + createdOn: currentTime, + updatedOn: currentTime, + deletedAt: null, +}; + +const userCollection: UserCollection = { + id: 'coll123', + title: 'Test Collection', + userUid: user.uid, + parentID: null, + orderIndex: 1, + type: 'REST', + data: {}, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const teamCollection: TeamCollection = { + id: 'team-coll123', + title: 'Team Collection', + teamID: 'team123', + parentID: null, + orderIndex: 1, + data: {}, + createdOn: currentTime, + updatedOn: currentTime, +}; + +describe('MockServerService', () => { + describe('getUserMockServers', () => { + test('should return user mock servers with pagination', async () => { + mockPrisma.mockServer.findMany.mockResolvedValue([dbMockServer]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(dbMockServer.id); + expect(result[0].name).toBe(dbMockServer.name); + expect(mockPrisma.mockServer.findMany).toHaveBeenCalledWith({ + where: { + workspaceType: WorkspaceType.USER, + creatorUid: user.uid, + deletedAt: null, + }, + orderBy: { createdOn: 'desc' }, + take: 10, + skip: 0, + }); + }); + + test('should return empty array when no mock servers exist', async () => { + mockPrisma.mockServer.findMany.mockResolvedValue([]); + + const result = await mockServerService.getUserMockServers(user.uid, { + take: 10, + skip: 0, + }); + + expect(result).toHaveLength(0); + }); + }); + + describe('getTeamMockServers', () => { + test('should return team mock servers with pagination', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + mockPrisma.mockServer.findMany.mockResolvedValue([teamMockServer]); + + const result = await mockServerService.getTeamMockServers('team123', { + take: 10, + skip: 0, + }); + + expect(result).toHaveLength(1); + expect(result[0].workspaceType).toBe(WorkspaceType.TEAM); + expect(mockPrisma.mockServer.findMany).toHaveBeenCalledWith({ + where: { + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + deletedAt: null, + }, + orderBy: { createdOn: 'desc' }, + take: 10, + skip: 0, + }); + }); + }); + + describe('getMockServer', () => { + test('should return mock server when user has access', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.getMockServer( + dbMockServer.id, + user.uid, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).id).toBe(dbMockServer.id); + expect((result.right as any).name).toBe(dbMockServer.name); + } + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = await mockServerService.getMockServer( + 'invalid-id', + user.uid, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should return error when user does not have access', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.getMockServer( + dbMockServer.id, + 'different-user', + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should allow team member access to team mock server', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + mockPrisma.mockServer.findFirst.mockResolvedValue(teamMockServer); + mockPrisma.team.findFirst.mockResolvedValue({ + id: 'team123', + name: 'Test Team', + } as any); + + const result = await mockServerService.getMockServer( + teamMockServer.id, + user.uid, + ); + + expect(E.isRight(result)).toBe(true); + }); + }); + + describe('getMockServerBySubdomain', () => { + test('should return active mock server by subdomain', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.getMockServerBySubdomain( + dbMockServer.subdomain, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).id).toBe(dbMockServer.id); + } + expect(mockPrisma.mockServer.findFirst).toHaveBeenCalledWith({ + where: { + subdomain: { equals: dbMockServer.subdomain, mode: 'insensitive' }, + isActive: true, + deletedAt: null, + }, + }); + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = + await mockServerService.getMockServerBySubdomain('non-existent'); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + }); + + describe('getMockServerCreator', () => { + test('should return mock server creator', async () => { + mockPrisma.mockServer.findUnique.mockResolvedValue({ + ...dbMockServer, + user: user as any, + } as any); + + const result = await mockServerService.getMockServerCreator( + dbMockServer.id, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).uid).toBe(user.uid); + } + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + + const result = await mockServerService.getMockServerCreator('invalid-id'); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + }); + + describe('getMockServerCollection', () => { + test('should return user collection for user workspace', async () => { + mockPrisma.mockServer.findUnique.mockResolvedValue(dbMockServer); + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + + const result = await mockServerService.getMockServerCollection( + dbMockServer.id, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).id).toBe(userCollection.id); + expect((result.right as any).title).toBe(userCollection.title); + } + }); + + test('should return team collection for team workspace', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + collectionID: teamCollection.id, + }; + mockPrisma.mockServer.findUnique.mockResolvedValue(teamMockServer); + mockPrisma.teamCollection.findUnique.mockResolvedValue(teamCollection); + + const result = await mockServerService.getMockServerCollection( + teamMockServer.id, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).id).toBe(teamCollection.id); + expect((result.right as any).title).toBe(teamCollection.title); + } + }); + + test('should return error when collection not found', async () => { + mockPrisma.mockServer.findUnique.mockResolvedValue(dbMockServer); + mockPrisma.userCollection.findUnique.mockResolvedValue(null); + + const result = await mockServerService.getMockServerCollection( + dbMockServer.id, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_INVALID_COLLECTION); + } + }); + }); + + describe('createMockServer', () => { + const createInput: CreateMockServerInput = { + name: 'New Mock Server', + collectionID: userCollection.id, + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + }; + + test('should create user mock server successfully', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + mockPrisma.mockServer.create.mockResolvedValue(dbMockServer); + + const result = await mockServerService.createMockServer( + user, + createInput, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).name).toBe(dbMockServer.name); + } + expect(mockAnalyticsService.recordActivity).toHaveBeenCalledWith( + dbMockServer, + MockServerAction.CREATED, + user.uid, + ); + }); + + test('should create team mock server successfully', async () => { + const teamInput: CreateMockServerInput = { + name: 'Team Mock Server', + collectionID: teamCollection.id, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + }; + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValue(teamCollection); + mockPrisma.mockServer.findUnique.mockResolvedValue(null); + mockPrisma.mockServer.create.mockResolvedValue(teamMockServer); + + const result = await mockServerService.createMockServer(user, teamInput); + + expect(E.isRight(result)).toBe(true); + }); + + test('should return error when collection not found', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(null); + + const result = await mockServerService.createMockServer( + user, + createInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_INVALID_COLLECTION); + } + }); + + test('should return error when team is invalid', async () => { + const teamInput: CreateMockServerInput = { + name: 'Team Mock Server', + collectionID: teamCollection.id, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'invalid-team', + delayInMs: 0, + }; + + mockPrisma.team.findFirst.mockResolvedValue(null); + + const result = await mockServerService.createMockServer(user, teamInput); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(TEAM_INVALID_ID); + } + }); + + test('should retry subdomain generation on conflict', async () => { + const PrismaError = { UNIQUE_CONSTRAINT_VIOLATION: 'P2002' }; + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.create + .mockRejectedValueOnce({ + code: PrismaError.UNIQUE_CONSTRAINT_VIOLATION, + }) // First attempt conflicts + .mockResolvedValueOnce(dbMockServer); // Second attempt succeeds + + const result = await mockServerService.createMockServer( + user, + createInput, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockPrisma.mockServer.create).toHaveBeenCalledTimes(2); + }); + + test('should return creation failed error on non-constraint errors', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.mockServer.create.mockRejectedValue( + new Error('Database error'), + ); + + const result = await mockServerService.createMockServer( + user, + createInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe('mock_server/creation_failed'); + } + }); + }); + + describe('updateMockServer', () => { + const updateInput: UpdateMockServerInput = { + name: 'Updated Name', + isActive: false, + delayInMs: 100, + }; + + test('should update mock server successfully', async () => { + const updatedMockServer = { ...dbMockServer, ...updateInput }; + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + mockPrisma.mockServer.update.mockResolvedValue(updatedMockServer); + + const result = await mockServerService.updateMockServer( + dbMockServer.id, + user.uid, + updateInput, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).name).toBe(updateInput.name); + } + expect(mockPrisma.mockServer.update).toHaveBeenCalledWith({ + where: { id: dbMockServer.id }, + data: updateInput, + }); + }); + + test('should record deactivation analytics', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + mockPrisma.mockServer.update.mockResolvedValue({ + ...dbMockServer, + isActive: false, + }); + + await mockServerService.updateMockServer(dbMockServer.id, user.uid, { + isActive: false, + }); + + expect(mockAnalyticsService.recordActivity).toHaveBeenCalledWith( + dbMockServer, + MockServerAction.DEACTIVATED, + user.uid, + ); + }); + + test('should record activation analytics', async () => { + const inactiveMockServer = { ...dbMockServer, isActive: false }; + mockPrisma.mockServer.findFirst.mockResolvedValue(inactiveMockServer); + mockPrisma.mockServer.update.mockResolvedValue({ + ...inactiveMockServer, + isActive: true, + }); + + await mockServerService.updateMockServer(dbMockServer.id, user.uid, { + isActive: true, + }); + + expect(mockAnalyticsService.recordActivity).toHaveBeenCalledWith( + inactiveMockServer, + MockServerAction.ACTIVATED, + user.uid, + ); + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = await mockServerService.updateMockServer( + 'invalid-id', + user.uid, + updateInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should return error when user lacks permission', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.updateMockServer( + dbMockServer.id, + 'different-user', + updateInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + }); + + describe('deleteMockServer', () => { + test('should soft delete mock server successfully', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + mockPrisma.mockServer.update.mockResolvedValue({ + ...dbMockServer, + isActive: false, + deletedAt: currentTime, + }); + + const result = await mockServerService.deleteMockServer( + dbMockServer.id, + user.uid, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBe(true); + } + expect(mockPrisma.mockServer.update).toHaveBeenCalledWith({ + where: { id: dbMockServer.id }, + data: { isActive: false, deletedAt: expect.any(Date) }, + }); + expect(mockAnalyticsService.recordActivity).toHaveBeenCalledWith( + dbMockServer, + MockServerAction.DELETED, + user.uid, + ); + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = await mockServerService.deleteMockServer( + 'invalid-id', + user.uid, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should return error when user lacks permission', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.deleteMockServer( + dbMockServer.id, + 'different-user', + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + }); + + describe('logRequest', () => { + const logParams = { + mockServerID: dbMockServer.id, + requestMethod: 'GET', + requestPath: '/api/users', + requestHeaders: { 'content-type': 'application/json' }, + requestBody: { test: 'data' }, + requestQuery: { page: '1' }, + responseStatus: 200, + responseHeaders: { 'content-type': 'application/json' }, + responseTime: 150, + ipAddress: '127.0.0.1', + userAgent: 'Mozilla/5.0', + }; + + test('should log request successfully', async () => { + mockPrisma.mockServerLog.create.mockResolvedValue({ + id: 'log123', + ...logParams, + responseBody: null, + executedAt: currentTime, + } as any); + + await mockServerService.logRequest(logParams); + + expect(mockPrisma.mockServerLog.create).toHaveBeenCalledWith({ + data: { + mockServerID: logParams.mockServerID, + requestMethod: logParams.requestMethod, + requestPath: logParams.requestPath, + requestHeaders: logParams.requestHeaders, + requestBody: logParams.requestBody, + requestQuery: logParams.requestQuery, + responseStatus: logParams.responseStatus, + responseHeaders: logParams.responseHeaders, + responseBody: null, + responseTime: logParams.responseTime, + ipAddress: logParams.ipAddress, + userAgent: logParams.userAgent, + }, + }); + }); + + test('should handle logging errors gracefully', async () => { + mockPrisma.mockServerLog.create.mockRejectedValue(new Error('DB Error')); + + // Should not throw + await expect( + mockServerService.logRequest(logParams), + ).resolves.not.toThrow(); + }); + }); + + describe('getMockServerLogs', () => { + const mockLog = { + id: 'log123', + mockServerID: dbMockServer.id, + requestMethod: 'GET', + requestPath: '/api/users', + requestHeaders: { 'content-type': 'application/json' }, + requestBody: null, + requestQuery: { page: '1' }, + responseStatus: 200, + responseHeaders: { 'content-type': 'application/json' }, + responseBody: null, + responseTime: 150, + ipAddress: '127.0.0.1', + userAgent: 'Mozilla/5.0', + executedAt: currentTime, + }; + + test('should return logs with pagination', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + mockPrisma.mockServerLog.findMany.mockResolvedValue([mockLog] as any); + + const result = await mockServerService.getMockServerLogs( + dbMockServer.id, + user.uid, + { take: 10, skip: 0 }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right as any).toHaveLength(1); + expect((result.right as any)[0].requestMethod).toBe('GET'); + expect((result.right as any)[0].requestHeaders).toBe( + JSON.stringify(mockLog.requestHeaders), + ); + } + expect(mockPrisma.mockServerLog.findMany).toHaveBeenCalledWith({ + where: { mockServerID: dbMockServer.id }, + orderBy: { executedAt: 'desc' }, + take: 10, + skip: 0, + }); + }); + + test('should return error when mock server not found', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(null); + + const result = await mockServerService.getMockServerLogs( + 'invalid-id', + user.uid, + { take: 10, skip: 0 }, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_NOT_FOUND); + } + }); + + test('should return error when user lacks access', async () => { + mockPrisma.mockServer.findFirst.mockResolvedValue(dbMockServer); + + const result = await mockServerService.getMockServerLogs( + dbMockServer.id, + 'different-user', + { take: 10, skip: 0 }, + ); + + expect(E.isLeft(result)).toBe(true); + }); + }); + + describe('deleteMockServerLog', () => { + const mockLog = { + id: 'log123', + mockServerID: dbMockServer.id, + mockServer: dbMockServer, + }; + + test('should delete log successfully', async () => { + mockPrisma.mockServerLog.findUnique.mockResolvedValue(mockLog as any); + mockPrisma.mockServerLog.delete.mockResolvedValue(mockLog as any); + + const result = await mockServerService.deleteMockServerLog( + 'log123', + user.uid, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toBe(true); + } + expect(mockPrisma.mockServerLog.delete).toHaveBeenCalledWith({ + where: { id: 'log123' }, + }); + }); + + test('should return error when log not found', async () => { + mockPrisma.mockServerLog.findUnique.mockResolvedValue(null); + + const result = await mockServerService.deleteMockServerLog( + 'invalid-id', + user.uid, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_LOG_NOT_FOUND); + } + }); + + test('should return error when user lacks permission', async () => { + mockPrisma.mockServerLog.findUnique.mockResolvedValue(mockLog as any); + + const result = await mockServerService.deleteMockServerLog( + 'log123', + 'different-user', + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe(MOCK_SERVER_LOG_NOT_FOUND); + } + }); + }); + + describe('incrementHitCount', () => { + test('should increment hit count and update last hit timestamp', async () => { + mockPrisma.mockServer.update.mockResolvedValue({ + ...dbMockServer, + hitCount: 1, + lastHitAt: currentTime, + }); + + await mockServerService.incrementHitCount(dbMockServer.id); + + expect(mockPrisma.mockServer.update).toHaveBeenCalledWith({ + where: { id: dbMockServer.id }, + data: { + hitCount: { increment: 1 }, + lastHitAt: expect.any(Date), + }, + }); + }); + + test('should handle errors gracefully', async () => { + mockPrisma.mockServer.update.mockRejectedValue(new Error('DB Error')); + + // Should not throw + await expect( + mockServerService.incrementHitCount(dbMockServer.id), + ).resolves.not.toThrow(); + }); + }); + + describe('handleMockRequest', () => { + const mockExample = { + key: 'example1', + name: 'Success Response', + method: 'GET', + endpoint: 'http://api.example.com/users?page=1', + statusCode: 200, + statusText: 'OK', + responseBody: '{"success": true}', + responseHeaders: [{ key: 'content-type', value: 'application/json' }], + headers: [], + }; + + const userRequest: UserRequest = { + id: 'req123', + collectionID: userCollection.id, + teamID: null, + title: 'Get Users', + request: {}, + mockExamples: { + examples: [mockExample], + }, + orderIndex: 1, + createdOn: currentTime, + updatedOn: currentTime, + } as any; + + test('should return example by ID header', async () => { + mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + {}, + { 'x-mock-response-id': 'example1' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + expect((result.right as any).body).toBe('{"success": true}'); + } + }); + + test('should return example by name header', async () => { + mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + {}, + { 'x-mock-response-name': 'Success Response' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + } + }); + + test('should filter by status code header', async () => { + const example404 = { + ...mockExample, + key: 'example2', + endpoint: 'http://api.example.com/users', // Same endpoint + statusCode: 404, + statusText: 'Not Found', + responseBody: '{"error": "not found"}', + }; + const requestWith404 = { + ...userRequest, + mockExamples: { + examples: [mockExample, example404], + }, + }; + + mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.userRequest.findMany.mockResolvedValue([ + requestWith404, + ] as any); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + {}, + { 'x-mock-response-code': '404' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(404); + expect((result.right as any).body).toBe('{"error": "not found"}'); + } + }); + + test('should match exact path', async () => { + mockPrisma.userRequest.findMany.mockResolvedValue([userRequest] as any); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + { page: '1' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + } + }); + + test('should match path with variables', async () => { + const variableExample = { + ...mockExample, + endpoint: 'http://api.example.com/users/<>', + }; + const variableRequest = { + ...userRequest, + mockExamples: { + examples: [variableExample], + }, + }; + + mockPrisma.userRequest.findMany.mockResolvedValue([ + variableRequest, + ] as any); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users/123', + 'GET', + ); + + expect(E.isRight(result)).toBe(true); + }); + + test('should return error when no examples found', async () => { + mockPrisma.userRequest.findMany.mockResolvedValue([]); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toContain('No examples found'); + } + }); + + test('should prefer 200 status when scores are equal', async () => { + const example200 = { ...mockExample, statusCode: 200 }; + const example404 = { ...mockExample, key: 'example2', statusCode: 404 }; + const multipleExamples = { + ...userRequest, + mockExamples: { + examples: [example404, example200], // 404 first, but 200 should be preferred + }, + }; + + mockPrisma.userRequest.findMany.mockResolvedValue([ + multipleExamples, + ] as any); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/users', + 'GET', + { page: '1' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + } + }); + + test('should include delay in response', async () => { + const delayedMockServer = { ...dbMockServer, delayInMs: 500 }; + const simpleRequest = { + ...userRequest, + mockExamples: { + examples: [ + { + ...mockExample, + endpoint: 'http://api.example.com/users', // Remove query params + }, + ], + }, + }; + + mockPrisma.userCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.userRequest.findMany.mockResolvedValue([simpleRequest] as any); + + const result = await mockServerService.handleMockRequest( + delayedMockServer, + '/users', + 'GET', + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).delay).toBe(500); + } + }); + + test('should work with team collections', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + collectionID: teamCollection.id, + }; + const teamRequest = { + ...userRequest, + collectionID: teamCollection.id, + mockExamples: { + examples: [ + { + ...mockExample, + endpoint: 'http://api.example.com/users', // Remove query params + }, + ], + }, + }; + + mockPrisma.teamCollection.findMany.mockResolvedValue([]); // No child collections + mockPrisma.teamRequest.findMany.mockResolvedValue([teamRequest] as any); + + const result = await mockServerService.handleMockRequest( + teamMockServer, + '/users', + 'GET', + ); + + expect(E.isRight(result)).toBe(true); + }); + }); + + describe('checkMockServerAccess', () => { + test('should allow user access to their own mock server', async () => { + const hasAccess = await mockServerService.checkMockServerAccess( + dbMockServer, + user.uid, + ); + + expect(hasAccess).toBe(true); + }); + + test('should deny user access to other users mock server', async () => { + const hasAccess = await mockServerService.checkMockServerAccess( + dbMockServer, + 'different-user', + ); + + expect(hasAccess).toBe(false); + }); + + test('should allow team member access to team mock server', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + + const hasAccess = await mockServerService.checkMockServerAccess( + teamMockServer, + user.uid, + ); + + expect(hasAccess).toBe(true); + }); + + test('should deny non-member access to team mock server', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + + mockPrisma.team.findFirst.mockResolvedValue(null); + + const hasAccess = await mockServerService.checkMockServerAccess( + teamMockServer, + user.uid, + ); + + expect(hasAccess).toBe(false); + }); + + test('should respect role restrictions', async () => { + const teamMockServer = { + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + }; + + mockPrisma.team.findFirst.mockResolvedValue(null); + + const hasAccess = await mockServerService.checkMockServerAccess( + teamMockServer, + user.uid, + [TeamAccessRole.OWNER], // Only owners + ); + + expect(hasAccess).toBe(false); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts new file mode 100644 index 00000000..3dab1df0 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts @@ -0,0 +1,1043 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { + CreateMockServerInput, + UpdateMockServerInput, + MockServerResponse, + MockServer, + MockServerCollection, + MockServerLog, +} from './mock-server.model'; +import { User } from 'src/user/user.model'; +import * as E from 'fp-ts/Either'; +import { + MOCK_SERVER_NOT_FOUND, + MOCK_SERVER_INVALID_COLLECTION, + TEAM_INVALID_ID, + MOCK_SERVER_CREATION_FAILED, + MOCK_SERVER_UPDATE_FAILED, + MOCK_SERVER_DELETION_FAILED, + MOCK_SERVER_LOG_NOT_FOUND, + MOCK_SERVER_LOG_DELETION_FAILED, +} from 'src/errors'; +import { randomBytes } from 'crypto'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; +import { + MockServerAction, + TeamAccessRole, + MockServer as dbMockServer, +} from '@prisma/client'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { ConfigService } from '@nestjs/config'; +import { MockServerAnalyticsService } from './mock-server-analytics.service'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; + +@Injectable() +export class MockServerService { + constructor( + private readonly mockServerAnalyticsService: MockServerAnalyticsService, + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} + + /** + * Cast database model to GraphQL model + */ + private cast(dbMockServer: dbMockServer): MockServer { + // Generate path based mock server URL + const backendUrl = this.configService.get('VITE_BACKEND_API_URL'); + const base = backendUrl.substring(0, backendUrl.lastIndexOf('/')); // "http(s)://localhost:3170" + const serverUrlPathBased = base + '/mock/' + dbMockServer.subdomain; + + // Generate domain based mock server URL + // MOCK_SERVER_WILDCARD_DOMAIN = '*.mock.hopp.io' + const wildcardDomain = this.configService.get( + 'INFRA.MOCK_SERVER_WILDCARD_DOMAIN', + ); + const isSecure = + this.configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true'; + const protocol = isSecure ? 'https://' : 'http://'; + const serverUrlDomainBased = wildcardDomain + ? protocol + dbMockServer.subdomain + wildcardDomain.substring(1) + : null; + + return { + id: dbMockServer.id, + name: dbMockServer.name, + subdomain: dbMockServer.subdomain, + serverUrlPathBased, + serverUrlDomainBased, + workspaceType: dbMockServer.workspaceType, + workspaceID: dbMockServer.workspaceID, + delayInMs: dbMockServer.delayInMs, + isActive: dbMockServer.isActive, + isPublic: dbMockServer.isPublic, + createdOn: dbMockServer.createdOn, + updatedOn: dbMockServer.updatedOn, + } as MockServer; + } + + /** + * Get mock servers for a user + */ + async getUserMockServers(userUid: string, args: OffsetPaginationArgs) { + const mockServers = await this.prisma.mockServer.findMany({ + where: { + workspaceType: WorkspaceType.USER, + creatorUid: userUid, + deletedAt: null, + }, + orderBy: { createdOn: 'desc' }, + take: args?.take, + skip: args?.skip, + }); + + return mockServers.map((ms) => this.cast(ms)); + } + + /** + * Get mock servers for a team + */ + async getTeamMockServers(teamID: string, args: OffsetPaginationArgs) { + const mockServers = await this.prisma.mockServer.findMany({ + where: { + workspaceType: WorkspaceType.TEAM, + workspaceID: teamID, + deletedAt: null, + }, + orderBy: { createdOn: 'desc' }, + take: args?.take, + skip: args?.skip, + }); + + return mockServers.map((ms) => this.cast(ms)); + } + + /** + * Check if user has access to a team with specific roles + */ + private async checkTeamAccess( + teamId: string, + userUid: string, + requiredRoles: TeamAccessRole[], + ): Promise { + const team = await this.prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { + userUid, + role: { in: requiredRoles }, + }, + }, + }, + }); + return !!team; + } + + /** + * Check if user has access to a mock server with specific roles + */ + async checkMockServerAccess( + mockServer: dbMockServer, + userUid: string, + requiredRoles: TeamAccessRole[] = [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ], + ): Promise { + if (mockServer.workspaceType === WorkspaceType.USER) { + return mockServer.creatorUid === userUid; + } else if (mockServer.workspaceType === WorkspaceType.TEAM) { + return this.checkTeamAccess( + mockServer.workspaceID, + userUid, + requiredRoles, + ); + } + return false; + } + + /** + * Get a specific mock server by ID + */ + async getMockServer(id: string, userUid: string) { + const mockServer = await this.prisma.mockServer.findFirst({ + where: { id, deletedAt: null }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Check access permissions + const hasAccess = await this.checkMockServerAccess(mockServer, userUid); + if (!hasAccess) return E.left(MOCK_SERVER_NOT_FOUND); + + return E.right(this.cast(mockServer)); + } + + /** + * Get a mock server by subdomain (for incoming mock requests) + * Returns database model with collectionID for internal use + * @param subdomain - The subdomain of the mock server + * @param includeInactive - If true, returns mock server regardless of active status (default: false) + */ + async getMockServerBySubdomain(subdomain: string, includeInactive = false) { + const mockServer = await this.prisma.mockServer.findFirst({ + where: { + subdomain: { equals: subdomain, mode: 'insensitive' }, + ...(includeInactive ? {} : { isActive: true }), + deletedAt: null, + }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Return database model directly (includes collectionID) + return E.right(mockServer); + } + + /** + * (Field resolver) + * Get the creator of a mock server + */ + async getMockServerCreator(mockServerId: string) { + const mockServer = await this.prisma.mockServer.findUnique({ + where: { id: mockServerId, deletedAt: null }, + include: { user: true }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + return E.right(mockServer.user); + } + + /** + * (Field resolver) + * Get the collection of a mock server + */ + async getMockServerCollection(mockServerId: string) { + const mockServer = await this.prisma.mockServer.findUnique({ + where: { id: mockServerId, deletedAt: null }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + if (mockServer.workspaceType === WorkspaceType.USER) { + const collection = await this.prisma.userCollection.findUnique({ + where: { id: mockServer.collectionID }, + }); + if (!collection) return E.left(MOCK_SERVER_INVALID_COLLECTION); + return E.right({ + id: collection.id, + title: collection.title, + } as MockServerCollection); + } else if (mockServer.workspaceType === WorkspaceType.TEAM) { + const collection = await this.prisma.teamCollection.findUnique({ + where: { id: mockServer.collectionID }, + }); + if (!collection) return E.left(MOCK_SERVER_INVALID_COLLECTION); + return E.right({ + id: collection.id, + title: collection.title, + } as MockServerCollection); + } + + return E.left(MOCK_SERVER_INVALID_COLLECTION); + } + + /** + * Generate a unique subdomain for the mock server + */ + private generateMockServerSubdomain(): string { + const id = randomBytes(10).toString('base64url').substring(0, 13); + return `${id}`; + } + + /** + * Validate workspace access permission and existence + */ + private async validateWorkspace(user: User, input: CreateMockServerInput) { + if (input.workspaceType === WorkspaceType.TEAM) { + if (!input.workspaceID) return E.left(TEAM_INVALID_ID); + + const hasAccess = await this.checkTeamAccess( + input.workspaceID, + user.uid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + + if (!hasAccess) return E.left(TEAM_INVALID_ID); + } + + return E.right(true); + } + + /** + * Validate collection exists and user has access + */ + private async validateCollection(user: User, input: CreateMockServerInput) { + if (input.workspaceType === WorkspaceType.TEAM) { + const collection = await this.prisma.teamCollection.findUnique({ + where: { id: input.collectionID, teamID: input.workspaceID }, + }); + return collection + ? E.right(collection) + : E.left(MOCK_SERVER_INVALID_COLLECTION); + } else if (input.workspaceType === WorkspaceType.USER) { + const collection = await this.prisma.userCollection.findUnique({ + where: { id: input.collectionID, userUid: user.uid }, + }); + return collection + ? E.right(collection) + : E.left(MOCK_SERVER_INVALID_COLLECTION); + } + + return E.left(MOCK_SERVER_INVALID_COLLECTION); + } + + /** + * Create a new mock server + */ + async createMockServer( + user: User, + input: CreateMockServerInput, + ): Promise> { + try { + // Validate workspace type and ID + const workspaceValidation = await this.validateWorkspace(user, input); + if (E.isLeft(workspaceValidation)) { + return E.left(workspaceValidation.left); + } + + // Validate collection exists and user has access + const collectionValidation = await this.validateCollection(user, input); + if (E.isLeft(collectionValidation)) { + return E.left(collectionValidation.left); + } + + // Create mock server + const subdomain: string = this.generateMockServerSubdomain(); + const mockServer = await this.prisma.mockServer.create({ + data: { + name: input.name, + subdomain, + creatorUid: user.uid, + collectionID: input.collectionID, + workspaceType: input.workspaceType, + workspaceID: + input.workspaceType === WorkspaceType.TEAM + ? input.workspaceID + : user.uid, + delayInMs: input.delayInMs, + }, + }); + this.mockServerAnalyticsService.recordActivity( + mockServer, + MockServerAction.CREATED, + user.uid, + ); + + return E.right(this.cast(mockServer)); + } catch (error) { + if (error.code === PrismaError.UNIQUE_CONSTRAINT_VIOLATION) { + return this.createMockServer(user, input); // Retry on subdomain conflict + } + console.error('Error creating mock server:', error); + return E.left(MOCK_SERVER_CREATION_FAILED); + } + } + + /** + * Update a mock server + */ + async updateMockServer( + id: string, + userUid: string, + input: UpdateMockServerInput, + ) { + try { + const mockServer = await this.prisma.mockServer.findFirst({ + where: { id, deletedAt: null }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Check access permissions (only OWNER and EDITOR can update) + const hasAccess = await this.checkMockServerAccess(mockServer, userUid, [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + ]); + if (!hasAccess) return E.left(MOCK_SERVER_NOT_FOUND); + + // Update the mock server + const updated = await this.prisma.mockServer.update({ + where: { id }, + data: input, + }); + if (input.isActive !== undefined) { + this.mockServerAnalyticsService.recordActivity( + mockServer, // use pre-update state to determine action + input.isActive + ? MockServerAction.ACTIVATED + : MockServerAction.DEACTIVATED, + userUid, + ); + } + + return E.right(this.cast(updated)); + } catch (error) { + console.error('Error updating mock server:', error); + return E.left(MOCK_SERVER_UPDATE_FAILED); + } + } + + /** + * Delete a mock server + */ + async deleteMockServer(id: string, userUid: string) { + try { + const mockServer = await this.prisma.mockServer.findFirst({ + where: { id, deletedAt: null }, + }); + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Check access permissions (only OWNER and EDITOR can delete) + const hasAccess = await this.checkMockServerAccess(mockServer, userUid, [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + ]); + if (!hasAccess) return E.left(MOCK_SERVER_NOT_FOUND); + + // Soft delete the mock server + await this.prisma.mockServer.update({ + where: { id }, + data: { isActive: false, deletedAt: new Date() }, + }); + this.mockServerAnalyticsService.recordActivity( + mockServer, // use pre-update state to determine action + MockServerAction.DELETED, + userUid, + ); + + return E.right(true); + } catch (error) { + console.error('Error deleting mock server:', error); + return E.left(MOCK_SERVER_DELETION_FAILED); + } + } + + /** + * Log a mock server request and response + */ + async logRequest(params: { + mockServerID: string; + requestMethod: string; + requestPath: string; + requestHeaders: Record; + requestBody?: any; + requestQuery?: Record; + responseStatus: number; + responseHeaders: Record; + responseTime: number; + ipAddress?: string; + userAgent?: string; + }): Promise { + try { + await this.prisma.mockServerLog.create({ + data: { + mockServerID: params.mockServerID, + requestMethod: params.requestMethod, + requestPath: params.requestPath, + requestHeaders: params.requestHeaders, + requestBody: params.requestBody || null, + requestQuery: params.requestQuery || null, + responseStatus: params.responseStatus, + responseHeaders: params.responseHeaders, + responseBody: null, // We'll capture response body separately if needed + responseTime: params.responseTime, + ipAddress: params.ipAddress || null, + userAgent: params.userAgent || null, + }, + }); + } catch (error) { + console.error('Error logging request:', error); + // Don't throw error - analytics shouldn't break the main flow + } + } + + /** + * Get logs for a mock server with pagination + * Logs are sorted by execution time in descending order (most recent first) + */ + async getMockServerLogs( + mockServerId: string, + userUid: string, + args: OffsetPaginationArgs, + ) { + try { + // First, get the mock server and verify it exists + const mockServer = await this.prisma.mockServer.findFirst({ + where: { id: mockServerId, deletedAt: null }, + }); + + if (!mockServer) return E.left(MOCK_SERVER_NOT_FOUND); + + // Check access permissions - user must have access to the mock server + const hasAccess = await this.checkMockServerAccess(mockServer, userUid, [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ]); + if (!hasAccess) return E.left(MOCK_SERVER_NOT_FOUND); + + // Fetch logs with pagination, sorted by executedAt descending + const logs = await this.prisma.mockServerLog.findMany({ + where: { mockServerID: mockServerId }, + orderBy: { executedAt: 'desc' }, + take: args?.take, + skip: args?.skip, + }); + + // Convert JSON fields to strings for GraphQL + const formattedLogs = logs.map( + (log) => + ({ + id: log.id, + mockServerID: log.mockServerID, + requestMethod: log.requestMethod, + requestPath: log.requestPath, + requestHeaders: JSON.stringify(log.requestHeaders), + requestBody: log.requestBody + ? JSON.stringify(log.requestBody) + : null, + requestQuery: log.requestQuery + ? JSON.stringify(log.requestQuery) + : null, + responseStatus: log.responseStatus, + responseHeaders: JSON.stringify(log.responseHeaders), + responseBody: log.responseBody + ? JSON.stringify(log.responseBody) + : null, + responseTime: log.responseTime, + ipAddress: log.ipAddress, + userAgent: log.userAgent, + executedAt: log.executedAt, + }) as MockServerLog, + ); + + return E.right(formattedLogs); + } catch (error) { + console.error('Error fetching mock server logs:', error); + return E.left(MOCK_SERVER_NOT_FOUND); + } + } + + /** + * Delete a mock server log by logId + */ + async deleteMockServerLog(logId: string, userUid: string) { + try { + // First, find the log and verify it exists + const log = await this.prisma.mockServerLog.findUnique({ + where: { id: logId }, + include: { mockServer: true }, + }); + + if (!log) return E.left(MOCK_SERVER_LOG_NOT_FOUND); + + // Check access permissions - user must have access to the mock server + const hasAccess = await this.checkMockServerAccess( + log.mockServer, + userUid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + if (!hasAccess) return E.left(MOCK_SERVER_LOG_NOT_FOUND); + + // Delete the log + await this.prisma.mockServerLog.delete({ + where: { id: logId }, + }); + + return E.right(true); + } catch (error) { + console.error('Error deleting mock server log:', error); + return E.left(MOCK_SERVER_LOG_DELETION_FAILED); + } + } + + /** + * Increment hit count and update last hit timestamp for a mock server + */ + async incrementHitCount(mockServerID: string): Promise { + try { + await this.prisma.mockServer.update({ + where: { id: mockServerID }, + data: { + hitCount: { increment: 1 }, + lastHitAt: new Date(), + }, + }); + } catch (error) { + console.error('Error incrementing hit count:', error); + // Don't throw error - analytics shouldn't break the main flow + } + } + + /** + * Handle mock request - find matching request in collection and return response + * Optimized implementation with database-level filtering: + * 1. Fetch collection IDs once (used for all subsequent queries) + * 2. Check custom headers first (fastest path) + * 3. Fetch only relevant requests from DB (filtered by collection) + * 4. Filter and score examples in-memory + * 5. Return highest scoring example + */ + async handleMockRequest( + mockServer: dbMockServer, + path: string, + method: string, + queryParams?: Record, + requestHeaders?: Record, + ): Promise> { + try { + // OPTIMIZATION: Fetch collection IDs once (recursive DB query) + // This is used by both custom header lookup and candidate fetching + const collectionIds = await this.getCollectionIds(mockServer); + + // OPTIMIZATION: Fetch all requests with examples once (single DB query) + // This is shared between custom header lookup and candidate matching + const requests = await this.fetchRequestsWithExamples( + mockServer, + collectionIds, + ); + + // OPTIMIZATION: Check for custom headers first (fastest path) + // If user specified exact example, return it immediately without scoring + if (requestHeaders) { + const mockResponseId = requestHeaders['x-mock-response-id']; + const mockResponseName = requestHeaders['x-mock-response-name']; + + if (mockResponseId || mockResponseName) { + const exactMatch = this.findExampleByIdOrName( + requests, + mockResponseId, + mockResponseName, + method, + ); + if (exactMatch) { + return this.formatExampleResponse(exactMatch, mockServer.delayInMs); + } + } + } + + // OPTIMIZATION: Fetch only requests with mockExamples (database-level filter) + // This is much faster than loading all requests and filtering in memory + const candidateExamples = this.fetchCandidateExamples( + requests, + method, + path, + ); + + if (candidateExamples.length === 0) { + return E.left(`No examples found for ${method.toUpperCase()} ${path}`); + } + + // OPTIMIZATION: Filter by status code if header provided + let filteredExamples = candidateExamples; + if (requestHeaders?.['x-mock-response-code']) { + const statusCode = parseInt(requestHeaders['x-mock-response-code'], 10); + const codeFiltered = candidateExamples.filter( + (ex) => ex.statusCode === statusCode, + ); + if (codeFiltered.length > 0) { + filteredExamples = codeFiltered; + } + } + + // OPTIMIZATION: Score examples based on URL and query parameter matching + const scoredExamples = filteredExamples + .map((example) => ({ + example, + score: this.calculateMatchScore(example, path, queryParams || {}), + })) + .filter((scored) => scored.score > 0) // Remove non-matching examples + .sort((a, b) => b.score - a.score); // Sort by score descending + + if (scoredExamples.length === 0) { + return E.left( + `No matching examples found for ${method.toUpperCase()} ${path}`, + ); + } + + // Step 6: Return highest scoring example + // If multiple examples have same high score, prefer 200 status code + const highestScore = scoredExamples[0].score; + const topExamples = scoredExamples.filter( + (scored) => scored.score === highestScore, + ); + + const selectedExample = + topExamples.find((scored) => scored.example.statusCode === 200) || + topExamples[0]; + + return this.formatExampleResponse( + selectedExample.example, + mockServer.delayInMs, + ); + } catch (error) { + console.error('Error handling mock request:', error); + return E.left('Failed to handle mock request'); + } + } + + /** + * Fetch all requests with mock examples from the database + * Shared helper to avoid code duplication + */ + private async fetchRequestsWithExamples( + mockServer: dbMockServer, + collectionIds: string[], + ) { + return mockServer.workspaceType === WorkspaceType.USER + ? await this.prisma.userRequest.findMany({ + where: { + collectionID: { in: collectionIds }, + mockExamples: { not: null }, + }, + select: { + id: true, + mockExamples: true, + }, + }) + : await this.prisma.teamRequest.findMany({ + where: { + collectionID: { in: collectionIds }, + mockExamples: { not: null }, + }, + select: { + id: true, + mockExamples: true, + }, + }); + } + + /** + * OPTIMIZED: Find example by ID or name from already-fetched requests + * This avoids loading all examples when user specifies exact match + */ + private findExampleByIdOrName( + requests: Array<{ id: string; mockExamples: any }>, + exampleId?: string, + exampleName?: string, + method?: string, + ) { + // Search through examples + for (const request of requests) { + const mockExamples = request.mockExamples as any; + if (mockExamples?.examples && Array.isArray(mockExamples.examples)) { + for (const exampleData of mockExamples.examples) { + // Check if method matches (if specified) + if ( + method && + exampleData.method?.toUpperCase() !== method.toUpperCase() + ) { + continue; + } + + const parsedExample = this.parseExample(exampleData, request.id); + if (!parsedExample) continue; + + // Check for ID match + if (exampleId && parsedExample.id === exampleId) { + return parsedExample; + } + + // Check for name match + if (exampleName && parsedExample.name === exampleName) { + return parsedExample; + } + } + } + } + + return null; + } + + /** + * OPTIMIZED: Fetch only candidate examples that could match the request + * Uses in-memory filtering from already-fetched requests + */ + private fetchCandidateExamples( + requests: Array<{ id: string; mockExamples: any }>, + method: string, + path: string, + ) { + interface Example { + id: string; + name: string; + method: string; + endpoint: string; + path: string; + queryParams: Record; + statusCode: number; + statusText: string; + responseBody: string; + responseHeaders: Array<{ key: string; value: string }>; + requestHeaders?: Array<{ key: string; value: string }>; + } + + const examples: Example[] = []; + + // Parse and filter examples + for (const request of requests) { + const mockExamples = request.mockExamples as any; + if (mockExamples?.examples && Array.isArray(mockExamples.examples)) { + for (const exampleData of mockExamples.examples) { + // OPTIMIZATION: Filter by method immediately + if (exampleData.method?.toUpperCase() !== method.toUpperCase()) { + continue; + } + + const parsedExample = this.parseExample(exampleData, request.id); + if (!parsedExample) continue; + + // OPTIMIZATION: Quick path match check before adding to candidates + // This reduces the number of examples we need to score + if (this.couldPathMatch(parsedExample.path, path)) { + examples.push(parsedExample); + } + } + } + } + + return examples; + } + + /** + * OPTIMIZED: Quick check if paths could potentially match + * Returns true if we should include this example for scoring + */ + private couldPathMatch(examplePath: string, requestPath: string): boolean { + // Exact match + if (examplePath === requestPath) return true; + + // Check if path structure could match (same number of segments) + const exampleParts = examplePath.split('/').filter(Boolean); + const requestParts = requestPath.split('/').filter(Boolean); + + if (exampleParts.length !== requestParts.length) { + return false; // Different structure, can't match + } + + // Quick check: if example has variables (Hoppscotch uses <> syntax), it could match + if (examplePath.includes('<<')) { + return true; // Has variables, needs full scoring + } + + // No variables and not exact match = no match + return false; + } + + /** + * Get collection IDs for the mock server (no caching) + */ + private async getCollectionIds(mockServer: dbMockServer): Promise { + return mockServer.workspaceType === WorkspaceType.USER + ? await this.getAllUserCollectionIds(mockServer.collectionID) + : await this.getAllTeamCollectionIds(mockServer.collectionID); + } + + /** + * Get all collection IDs including children (recursive) + */ + private async getAllUserCollectionIds( + rootCollectionId: string, + ): Promise { + const ids = [rootCollectionId]; + const children = await this.prisma.userCollection.findMany({ + where: { parentID: rootCollectionId }, + select: { id: true }, + }); + + for (const child of children) { + const childIds = await this.getAllUserCollectionIds(child.id); + ids.push(...childIds); + } + + return ids; + } + + /** + * Get all team collection IDs including children (recursive) + */ + private async getAllTeamCollectionIds( + rootCollectionId: string, + ): Promise { + const ids = [rootCollectionId]; + const children = await this.prisma.teamCollection.findMany({ + where: { parentID: rootCollectionId }, + select: { id: true }, + }); + + for (const child of children) { + const childIds = await this.getAllTeamCollectionIds(child.id); + ids.push(...childIds); + } + + return ids; + } + + /** + * Parse example from database format to internal format + */ + private parseExample(exampleData: any, requestId: string) { + try { + // Parse endpoint to extract path and query parameters + let path = '/'; + const queryParams: Record = {}; + + if (exampleData.endpoint) { + const url = new URL( + exampleData.endpoint, + 'http://dummy.com', // Base URL for parsing + ); + // Decode the pathname to preserve Hoppscotch variable syntax (<>) + path = decodeURIComponent(url.pathname); + + // Extract query parameters + url.searchParams.forEach((value, key) => { + queryParams[key] = value; + }); + } + + return { + id: exampleData.key || `${requestId}-${exampleData.name}`, + name: exampleData.name, + method: exampleData.method || 'GET', + endpoint: exampleData.endpoint, + path, + queryParams, + statusCode: exampleData.statusCode || 200, + statusText: exampleData.statusText || 'OK', + responseBody: exampleData.responseBody || '', + responseHeaders: exampleData.responseHeaders || [], + requestHeaders: exampleData.headers || [], + }; + } catch (error) { + console.error('Error parsing example:', error); + return null; + } + } + + /** + * Calculate match score for an example based on Postman's algorithm + * Starting score: 100 + * URL path match: exact match keeps 100, no match = 0 + * Query parameters: percentage based on matches + */ + private calculateMatchScore( + example: any, + requestPath: string, + requestQueryParams: Record, + ): number { + let score = 100; + + // URL Path matching + if (example.path !== requestPath) { + // Try wildcard matching (basic implementation) + const examplePathParts = example.path.split('/').filter(Boolean); + const requestPathParts = requestPath.split('/').filter(Boolean); + + if (examplePathParts.length !== requestPathParts.length) { + return 0; // Path structure doesn't match + } + + // Check each segment + let pathMatches = true; + for (let i = 0; i < examplePathParts.length; i++) { + const examplePart = examplePathParts[i]; + const requestPart = requestPathParts[i]; + + // Check if it's a variable (Hoppscotch uses <> syntax) + if ( + examplePart === requestPart || + examplePart.startsWith('<<') || + examplePart.includes('<<') + ) { + continue; // Match + } else { + pathMatches = false; + break; + } + } + + if (!pathMatches) { + return 0; // No path match + } + + // Path has variables, reduce score slightly + score -= 5; + } + + // Query parameter matching + const exampleParams = example.queryParams || {}; + const exampleParamKeys = Object.keys(exampleParams); + const requestParamKeys = Object.keys(requestQueryParams); + + if (exampleParamKeys.length > 0 || requestParamKeys.length > 0) { + let paramMatches = 0; + let partialMatches = 0; + let missingParams = 0; + + // Check for matches + exampleParamKeys.forEach((key) => { + if (requestQueryParams[key] !== undefined) { + if (requestQueryParams[key] === exampleParams[key]) { + paramMatches++; + } else { + partialMatches++; + } + } else { + missingParams++; + } + }); + + // Check for extra params in request + requestParamKeys.forEach((key) => { + if (exampleParams[key] === undefined) { + missingParams++; + } + }); + + // Calculate parameter matching percentage + const totalParams = paramMatches + partialMatches + missingParams; + if (totalParams > 0) { + const matchPercentage = (paramMatches / totalParams) * 100; + // Adjust score based on parameter matching + score = score * (matchPercentage / 100); + } + } + + return score; + } + + /** + * Format example response for return + */ + private formatExampleResponse( + example: any, + delayInMs: number, + ): E.Either { + // Convert response headers array to object + const headersObj: Record = {}; + if (example.responseHeaders && Array.isArray(example.responseHeaders)) { + example.responseHeaders.forEach((header: any) => { + if (header.key && header.value) { + headersObj[header.key] = header.value; + } + }); + } + + return E.right({ + statusCode: example.statusCode || 200, + body: example.responseBody || '', + headers: JSON.stringify(headersObj), + delay: delayInMs || 0, + }); + } +} diff --git a/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts index 5a9db450..8a1d1e8a 100644 --- a/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts +++ b/packages/hoppscotch-backend/src/orchestration/sort/sort.service.spec.ts @@ -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'; diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts index e5bd38f1..44d8064b 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -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'; diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts index 5388c84c..feda8ed1 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts @@ -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(), diff --git a/packages/hoppscotch-backend/src/types/InfraConfig.ts b/packages/hoppscotch-backend/src/types/InfraConfig.ts index 6563ae63..3f5c56d1 100644 --- a/packages/hoppscotch-backend/src/types/InfraConfig.ts +++ b/packages/hoppscotch-backend/src/types/InfraConfig.ts @@ -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', } diff --git a/packages/hoppscotch-backend/src/types/WorkspaceTypes.ts b/packages/hoppscotch-backend/src/types/WorkspaceTypes.ts new file mode 100644 index 00000000..ea85b419 --- /dev/null +++ b/packages/hoppscotch-backend/src/types/WorkspaceTypes.ts @@ -0,0 +1,4 @@ +export enum WorkspaceType { + USER = 'USER', + TEAM = 'TEAM', +} diff --git a/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts b/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts index 3aefec33..69618a6d 100644 --- a/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts +++ b/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts @@ -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(), diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts index 5b880cfd..d9c31361 100644 --- a/packages/hoppscotch-backend/src/user/user.service.ts +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -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'; diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 81c128f7..932d4813 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -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" + } } } diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 7aa85941..e356b63f 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -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'] diff --git a/packages/hoppscotch-common/src/components/collections/Collection.vue b/packages/hoppscotch-common/src/components/collections/Collection.vue index 8a7364d6..23cac282 100644 --- a/packages/hoppscotch-common/src/components/collections/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/Collection.vue @@ -56,6 +56,26 @@ {{ collectionName }} + + + +
+ (null) const duplicateAction = ref(null) const deleteAction = ref(null) const exportAction = ref(null) +const mockServerAction = ref(null) const options = ref(null) const propertiesAction = ref(null) const runCollectionAction = ref(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 diff --git a/packages/hoppscotch-common/src/components/collections/MyCollections.vue b/packages/hoppscotch-common/src/components/collections/MyCollections.vue index 7d088129..2e9ba042 100644 --- a/packages/hoppscotch-common/src/components/collections/MyCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/MyCollections.vue @@ -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") diff --git a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue index afa57e93..e7ba5712 100644 --- a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue @@ -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) diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 8178a6f4..f71cab6b 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -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" /> + +
@@ -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 diff --git a/packages/hoppscotch-common/src/components/http/Sidebar.vue b/packages/hoppscotch-common/src/components/http/Sidebar.vue index b62836b1..aff2b99b 100644 --- a/packages/hoppscotch-common/src/components/http/Sidebar.vue +++ b/packages/hoppscotch-common/src/components/http/Sidebar.vue @@ -51,6 +51,18 @@ class="px-4 mt-4" /> + +
+ {{ t("tab.mock_servers") }} +
+ +
@@ -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("collections") diff --git a/packages/hoppscotch-common/src/components/mockServer/CreateMockServer.vue b/packages/hoppscotch-common/src/components/mockServer/CreateMockServer.vue new file mode 100644 index 00000000..d330a351 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/CreateMockServer.vue @@ -0,0 +1,550 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/EditMockServer.vue b/packages/hoppscotch-common/src/components/mockServer/EditMockServer.vue new file mode 100644 index 00000000..bf900a51 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/EditMockServer.vue @@ -0,0 +1,209 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/MockServerDashboard.vue b/packages/hoppscotch-common/src/components/mockServer/MockServerDashboard.vue new file mode 100644 index 00000000..0de14ae8 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/MockServerDashboard.vue @@ -0,0 +1,403 @@ + + + diff --git a/packages/hoppscotch-common/src/components/mockServer/MockServerLogs.vue b/packages/hoppscotch-common/src/components/mockServer/MockServerLogs.vue new file mode 100644 index 00000000..a226c0a0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/mockServer/MockServerLogs.vue @@ -0,0 +1,162 @@ + + + diff --git a/packages/hoppscotch-common/src/composables/mockServer.ts b/packages/hoppscotch-common/src/composables/mockServer.ts new file mode 100644 index 00000000..ac3c7e35 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/mockServer.ts @@ -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, + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateMockServer.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateMockServer.graphql new file mode 100644 index 00000000..8e48e024 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreateMockServer.graphql @@ -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 + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServer.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServer.graphql new file mode 100644 index 00000000..59208090 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServer.graphql @@ -0,0 +1,3 @@ +mutation DeleteMockServer($id: ID!) { + deleteMockServer(id: $id) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServerLog.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServerLog.graphql new file mode 100644 index 00000000..0c916e91 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeleteMockServerLog.graphql @@ -0,0 +1,3 @@ +mutation DeleteMockServerLog($logID: ID!) { + deleteMockServerLog(logID: $logID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateMockServer.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateMockServer.graphql new file mode 100644 index 00000000..728458f7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdateMockServer.graphql @@ -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 + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServer.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServer.graphql new file mode 100644 index 00000000..9839596c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServer.graphql @@ -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 + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServerLogs.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServerLogs.graphql new file mode 100644 index 00000000..a3f1d7f6 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMockServerLogs.graphql @@ -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 + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyMockServers.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyMockServers.graphql new file mode 100644 index 00000000..6da2d115 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetMyMockServers.graphql @@ -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 + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMockServers.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMockServers.graphql new file mode 100644 index 00000000..1ac7fd4f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/GetTeamMockServers.graphql @@ -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 + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts new file mode 100644 index 00000000..0d14f3c7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts @@ -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 + ) diff --git a/packages/hoppscotch-common/src/helpers/backend/queries/MockServer.ts b/packages/hoppscotch-common/src/helpers/backend/queries/MockServer.ts new file mode 100644 index 00000000..43538cd0 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/queries/MockServer.ts @@ -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 + ) diff --git a/packages/hoppscotch-common/src/helpers/backend/queries/MockServerLogs.ts b/packages/hoppscotch-common/src/helpers/backend/queries/MockServerLogs.ts new file mode 100644 index 00000000..2e30b2c1 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/queries/MockServerLogs.ts @@ -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 + ) diff --git a/packages/hoppscotch-common/src/newstore/mockServers.ts b/packages/hoppscotch-common/src/newstore/mockServers.ts new file mode 100644 index 00000000..6a1f415e --- /dev/null +++ b/packages/hoppscotch-common/src/newstore/mockServers.ts @@ -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 } + ) { + 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) { + 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) +} diff --git a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts index e774fb4e..f4c535c2 100644 --- a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts +++ b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts @@ -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() } }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts index ce2f0637..5d64aeb2 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts @@ -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() } }) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts index 348eb46b..0e315d63 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts @@ -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() } }) diff --git a/packages/hoppscotch-sh-admin/locales/en.json b/packages/hoppscotch-sh-admin/locales/en.json index 3d9ff4b6..d972d9d0 100644 --- a/packages/hoppscotch-sh-admin/locales/en.json +++ b/packages/hoppscotch-sh-admin/locales/en.json @@ -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": { diff --git a/packages/hoppscotch-sh-admin/src/components.d.ts b/packages/hoppscotch-sh-admin/src/components.d.ts index 04f55310..4b350a54 100644 --- a/packages/hoppscotch-sh-admin/src/components.d.ts +++ b/packages/hoppscotch-sh-admin/src/components.d.ts @@ -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'] diff --git a/packages/hoppscotch-sh-admin/src/components/settings/MockServerConfig.vue b/packages/hoppscotch-sh-admin/src/components/settings/MockServerConfig.vue new file mode 100644 index 00000000..172a9f9f --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/settings/MockServerConfig.vue @@ -0,0 +1,85 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts index 7501512d..3b4d4f6b 100644 --- a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts +++ b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts @@ -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[] = []; diff --git a/packages/hoppscotch-sh-admin/src/helpers/configs.ts b/packages/hoppscotch-sh-admin/src/helpers/configs.ts index 9dcaf118..980a8405 100644 --- a/packages/hoppscotch-sh-admin/src/helpers/configs.ts +++ b/packages/hoppscotch-sh-admin/src/helpers/configs.ts @@ -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, ]; diff --git a/packages/hoppscotch-sh-admin/src/pages/settings.vue b/packages/hoppscotch-sh-admin/src/pages/settings.vue index d8ef68c1..200ceeac 100644 --- a/packages/hoppscotch-sh-admin/src/pages/settings.vue +++ b/packages/hoppscotch-sh-admin/src/pages/settings.vue @@ -37,9 +37,13 @@
+
+ + +