diff --git a/package.json b/package.json index 31d74b362f4..022cf7c2c62 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Hoppscotch (support@hoppscotch.io)", "private": true, "license": "MIT", - "packageManager": "pnpm@10.34.2", + "packageManager": "pnpm@10.34.5", "scripts": { "preinstall": "npx only-allow pnpm", "prepare": "husky", @@ -35,25 +35,31 @@ }, "pnpm": { "overrides": { + "@protobufjs/utf8@<=1.1.0": "1.1.2", "@xmldom/xmldom": "0.8.13", "apiconnect-wsdl": "2.0.36", - "body-parser": "2.2.1", - "brace-expansion@<=5.0.7": "5.0.8", + "body-parser": "2.3.0", + "brace-expansion@<5.0.9": "5.0.9", "cross-spawn": "7.0.6", + "deepmerge-ts@<8.0.0": "8.0.1", "execa@<2.0.0": "2.0.0", - "fast-uri@3.1.2": "3.1.4", + "fast-uri@>=3.0.0 <3.1.5": "3.1.5", "find-my-way@9.6.0": "9.7.0", "form-data@>=4.0.0 <4.0.6": "4.0.6", - "js-yaml@>=4.0.0 <4.3.0": "4.3.0", + "js-yaml@>=4.0.0 <4.3.1": "4.3.1", "js-yaml@>=5.0.0 <=5.2.1": "5.2.2", "linkify-it@<=5.0.1": "5.0.2", "liquidjs@<10.27.0": "10.27.1", "minimatch@>=4.0.0 <4.2.5": "4.2.5", + "mjml@<5.0.0-alpha.9": "5.4.0", "nodemailer@<=9.0.0": "9.0.1", - "postcss@<=8.5.17": "8.5.18", + "postcss@<=8.5.22": "8.5.26", + "qs@>=6.11.1 <6.15.2": "6.15.3", "serialize-javascript@<7.0.7": "7.0.7", "svgo@>=4.0.0 <4.0.2": "4.0.2", - "vue": "3.5.40", + "uuid@9.0.1": "11.1.1", + "valibot@<=1.4.1": "1.4.2", + "vue": "3.5.41", "ws": "8.21.0" }, "onlyBuiltDependencies": [ diff --git a/packages/codemirror-lang-graphql/package.json b/packages/codemirror-lang-graphql/package.json index 6f144921ccf..c4439d314b4 100644 --- a/packages/codemirror-lang-graphql/package.json +++ b/packages/codemirror-lang-graphql/package.json @@ -24,7 +24,7 @@ "devDependencies": { "@lezer/generator": "1.8.0", "@rollup/plugin-typescript": "12.1.4", - "mocha": "11.7.6", + "mocha": "11.8.0", "rollup": "4.59.0", "typescript": "5.9.3" } diff --git a/packages/hoppscotch-agent/package.json b/packages/hoppscotch-agent/package.json index f4c0e65d9d8..c07de4a8e2e 100644 --- a/packages/hoppscotch-agent/package.json +++ b/packages/hoppscotch-agent/package.json @@ -20,14 +20,14 @@ "@hoppscotch/ui": "0.2.6", "@tauri-apps/api": "2.1.1", "@tauri-apps/plugin-shell": "2.3.3", - "@vueuse/core": "14.3.0", - "axios": "1.18.1", + "@vueuse/core": "14.4.0", + "axios": "1.19.0", "fp-ts": "2.16.11", "lodash-es": "4.18.1", - "vue": "3.5.38" + "vue": "3.5.41" }, "devDependencies": { - "@iconify-json/lucide": "1.2.118", + "@iconify-json/lucide": "1.2.125", "@tauri-apps/cli": "2.9.3", "@types/lodash-es": "4.17.12", "@types/node": "24.10.1", @@ -39,9 +39,9 @@ "cross-env": "10.1.0", "eslint": "9.39.2", "eslint-plugin-prettier": "5.5.6", - "eslint-plugin-vue": "10.9.2", + "eslint-plugin-vue": "10.10.0", "globals": "16.5.0", - "postcss": "8.5.20", + "postcss": "8.5.26", "tailwindcss": "3.4.16", "typescript": "5.9.3", "unplugin-icons": "22.5.0", diff --git a/packages/hoppscotch-backend/package.json b/packages/hoppscotch-backend/package.json index 34ca930a154..25c961b1de0 100644 --- a/packages/hoppscotch-backend/package.json +++ b/packages/hoppscotch-backend/package.json @@ -1,6 +1,6 @@ { "name": "hoppscotch-backend", - "version": "2026.7.0", + "version": "2026.8.0", "description": "", "author": "", "private": true, @@ -34,21 +34,21 @@ "@apollo/server": "5.5.1", "@as-integrations/express5": "1.1.2", "@nestjs-modules/mailer": "2.3.7", - "@nestjs/apollo": "13.4.2", - "@nestjs/common": "11.1.28", + "@nestjs/apollo": "13.4.5", + "@nestjs/common": "11.2.1", "@nestjs/config": "4.0.4", - "@nestjs/core": "11.1.28", - "@nestjs/graphql": "13.4.2", + "@nestjs/core": "11.2.1", + "@nestjs/graphql": "13.4.5", "@nestjs/jwt": "11.0.2", "@nestjs/passport": "11.0.0", - "@nestjs/platform-express": "11.1.28", + "@nestjs/platform-express": "11.2.1", "@nestjs/schedule": "6.1.3", - "@nestjs/swagger": "11.4.6", + "@nestjs/swagger": "11.4.7", "@nestjs/terminus": "11.1.1", "@nestjs/throttler": "6.5.0", - "@prisma/adapter-pg": "7.9.0", - "@prisma/client": "7.9.0", - "argon2": "0.44.0", + "@prisma/adapter-pg": "7.9.1", + "@prisma/client": "7.9.1", + "argon2": "0.45.1", "bcrypt": "6.0.0", "class-transformer": "0.5.1", "class-validator": "0.15.1", @@ -64,16 +64,16 @@ "handlebars": "4.7.9", "io-ts": "2.2.22", "morgan": "1.11.0", - "nodemailer": "9.0.3", + "nodemailer": "9.0.5", "passport": "0.7.0", "passport-github2": "0.1.12", "passport-google-oauth20": "2.0.0", "passport-jwt": "4.0.1", "passport-local": "1.0.0", "passport-microsoft": "2.1.0", - "pg": "8.22.0", - "posthog-node": "5.46.1", - "prisma": "7.9.0", + "pg": "8.23.0", + "posthog-node": "5.50.0", + "prisma": "7.9.1", "reflect-metadata": "0.2.2", "rimraf": "6.1.3", "rxjs": "7.8.2" @@ -83,7 +83,7 @@ "@eslint/js": "10.0.1", "@nestjs/cli": "11.0.24", "@nestjs/schematics": "11.1.0", - "@nestjs/testing": "11.1.28", + "@nestjs/testing": "11.2.1", "@relmify/jest-fp-ts": "2.1.1", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", @@ -96,13 +96,13 @@ "@types/passport-jwt": "4.0.1", "@types/passport-microsoft": "2.1.1", "@types/supertest": "7.2.1", - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", "cross-env": "10.1.0", - "eslint": "10.7.0", + "eslint": "10.8.1", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "5.5.6", - "globals": "17.7.0", + "globals": "17.11.0", "jest": "30.4.2", "jest-mock-extended": "4.0.1", "prettier": "3.9.6", diff --git a/packages/hoppscotch-backend/prisma/migrations/20260713090000_mock_server_gql_examples/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20260713090000_mock_server_gql_examples/migration.sql new file mode 100644 index 00000000000..75f2893f054 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20260713090000_mock_server_gql_examples/migration.sql @@ -0,0 +1,55 @@ +-- Extend sync_mock_examples() to project the GraphQL operation identity of +-- saved example responses (operationName / operationType, stamped by the app +-- when a GraphQL run is saved as an example). The mock server's GraphQL +-- matcher selects examples by these, the way REST examples match by +-- method + path. REST examples project both fields as NULL. +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), + 'operationName', value->>'operationName', + 'operationType', value->>'operationType' + ) + ) + FROM jsonb_each(NEW.request->'responses') AS responses(key, value) + WHERE jsonb_typeof(NEW.request->'responses') = 'object' + ), + '[]'::jsonb + ) + ); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Backfill: re-fire the trigger so existing rows pick up the new projection. +-- +-- Scoped to rows that actually carry saved example responses. `request` is +-- NOT NULL on both tables, so an `IS NOT NULL` guard would filter nothing and +-- rewrite every request row in the database — a full-table rewrite (plus bloat +-- and a long lock hold on large self-hosted instances) to recompute a value +-- that is unchanged for those rows: with no `responses` object the projection +-- COALESCEs to the same `{"examples": []}` the previous trigger produced. +UPDATE "UserRequest" +SET request = request +WHERE jsonb_typeof(request -> 'responses') = 'object' + AND request -> 'responses' <> '{}'::jsonb; + +UPDATE "TeamRequest" +SET request = request +WHERE jsonb_typeof(request -> 'responses') = 'object' + AND request -> 'responses' <> '{}'::jsonb; diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 26a3b263059..54d8f25c435 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -7,14 +7,46 @@ datasource db { provider = "postgresql" } +// ============================================================ +// ENUMS +// ============================================================ + +enum WorkspaceType { + USER + TEAM +} + +enum ReqType { + REST + GQL +} + +enum TeamAccessRole { + OWNER + VIEWER + EDITOR +} + +enum MockServerAction { + CREATED + DELETED + ACTIVATED + DEACTIVATED +} + +// ============================================================ +// TEAM +// ============================================================ + model Team { - id String @id @default(cuid()) - name String - TeamCollection TeamCollection[] - TeamEnvironment TeamEnvironment[] - TeamInvitation TeamInvitation[] - members TeamMember[] - TeamRequest TeamRequest[] + id String @id @default(cuid()) + name String + + collections TeamCollection[] + environments TeamEnvironment[] + invitations TeamInvitation[] + members TeamMember[] + requests TeamRequest[] } model TeamMember { @@ -22,7 +54,8 @@ model TeamMember { role TeamAccessRole userUid String teamID String - team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) @@unique([teamID, userUid]) } @@ -33,55 +66,46 @@ model TeamInvitation { creatorUid String inviteeEmail String inviteeRole TeamAccessRole - team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) @@unique([teamID, inviteeEmail]) @@index([teamID]) } model TeamCollection { - id String @id @default(cuid()) + id String @id @default(cuid()) parentID String? teamID String 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], onDelete: Cascade) - children TeamCollection[] @relation("TeamCollectionChildParent") - team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) - requests TeamRequest[] + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + + parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id], onDelete: Cascade) + children TeamCollection[] @relation("TeamCollectionChildParent") + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + requests TeamRequest[] @@unique([teamID, parentID, orderIndex]) } model TeamRequest { - id String @id @default(cuid()) + id String @id @default(cuid()) collectionID String teamID String 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) + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) - @@unique([teamID, collectionID, orderIndex]) -} - -model Shortcode { - id String @id @unique - request Json - creatorUid String? - createdOn DateTime @default(now()) @db.Timestamptz(3) - embedProperties Json? - updatedOn DateTime @default(now()) @updatedAt @db.Timestamptz(3) - User User? @relation(fields: [creatorUid], references: [uid]) + collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) - @@unique([id, creatorUid], name: "creator_uid_shortcode_unique") + @@unique([teamID, collectionID, orderIndex]) } model TeamEnvironment { @@ -89,32 +113,38 @@ model TeamEnvironment { teamID String name String variables Json - team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) } +// ============================================================ +// USER +// ============================================================ + model User { - uid String @id @default(cuid()) - displayName String? - email String? @unique - photoURL String? - isAdmin Boolean @default(false) - refreshToken String? - currentRESTSession Json? - currentGQLSession Json? - createdOn DateTime @default(now()) @db.Timestamptz(3) - lastLoggedOn DateTime? @db.Timestamptz(3) - lastActiveOn DateTime? @db.Timestamptz(3) + uid String @id @default(cuid()) + displayName String? + email String? @unique + photoURL String? + isAdmin Boolean @default(false) + refreshToken String? + currentRESTSession Json? + currentGQLSession Json? + createdOn DateTime @default(now()) @db.Timestamptz(3) + lastLoggedOn DateTime? @db.Timestamptz(3) + lastActiveOn DateTime? @db.Timestamptz(3) + providerAccounts Account[] invitedUsers InvitedUsers[] mockServers MockServer[] personalAccessTokens PersonalAccessToken[] shortcodes Shortcode[] userCollections UserCollection[] - UserEnvironments UserEnvironment[] - UserHistory UserHistory[] + environments UserEnvironment[] + history UserHistory[] userRequests UserRequest[] settings UserSettings? - VerificationToken VerificationToken[] + verificationTokens VerificationToken[] } model Account { @@ -126,7 +156,8 @@ model Account { providerAccessToken String? providerScope String? loggedIn DateTime @default(now()) @db.Timestamptz(3) - user User @relation(fields: [userId], references: [uid], onDelete: Cascade) + + user User @relation(fields: [userId], references: [uid], onDelete: Cascade) @@unique([provider, providerAccountId], name: "verifyProviderAccount") } @@ -136,7 +167,8 @@ model VerificationToken { token String @unique @default(cuid()) userUid String expiresOn DateTime @db.Timestamptz(3) - user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) @@unique([deviceIdentifier, token], name: "passwordless_deviceIdentifier_tokens") } @@ -146,7 +178,8 @@ model UserSettings { userUid String @unique properties Json updatedOn DateTime @updatedAt @db.Timestamptz(3) - user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) } model UserHistory { @@ -157,7 +190,8 @@ model UserHistory { responseMetadata Json isStarred Boolean executedOn DateTime @default(now()) @db.Timestamptz(3) - user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) } model UserEnvironment { @@ -166,7 +200,8 @@ model UserEnvironment { name String? variables Json isGlobal Boolean - user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) + + user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) } model InvitedUsers { @@ -174,20 +209,22 @@ model InvitedUsers { adminEmail String inviteeEmail String @unique invitedOn DateTime @default(now()) @db.Timestamptz(3) - user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade) + + user User @relation(fields: [adminUid], references: [uid], onDelete: Cascade) } model UserRequest { - id String @id @default(cuid()) - collectionID String - userUid String - title String - request Json - mockExamples Json? - type ReqType - orderIndex Int - createdOn DateTime @default(now()) @db.Timestamptz(3) - updatedOn DateTime @updatedAt @db.Timestamptz(3) + id String @id @default(cuid()) + collectionID String + userUid String + title String + request Json + type ReqType + mockExamples Json? + orderIndex Int + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) + userCollection UserCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) user User @relation(fields: [userUid], references: [uid], onDelete: Cascade) @@ -195,77 +232,52 @@ model UserRequest { } model UserCollection { - id String @id @default(cuid()) + id String @id @default(cuid()) parentID String? userUid String title String - orderIndex Int type ReqType - createdOn DateTime @default(now()) @db.Timestamptz(3) - updatedOn DateTime @updatedAt @db.Timestamptz(3) + orderIndex Int 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]) -} + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) -model InfraConfig { - id String @id @default(cuid()) - name String @unique - value String? - createdOn DateTime @default(now()) @db.Timestamptz(3) - updatedOn DateTime @updatedAt @db.Timestamptz(3) - isEncrypted Boolean @default(false) - lastSyncedEnvFileValue String? -} + 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[] -model PersonalAccessToken { - id String @id @default(cuid()) - userUid String - 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) + @@unique([userUid, parentID, orderIndex]) } -model InfraToken { - id String @id @default(cuid()) - creatorUid String - label String - token String @unique @default(uuid()) - expiresOn DateTime? @db.Timestamptz(3) - createdOn DateTime @default(now()) @db.Timestamptz(3) - updatedOn DateTime @default(now()) @db.Timestamptz(3) -} +// ============================================================ +// MOCK SERVER +// ============================================================ 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) + 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()) + id String @id @default(cuid()) mockServerID String requestMethod String requestPath String @@ -278,8 +290,9 @@ model MockServerLog { responseTime Int ipAddress String? userAgent String? - executedAt DateTime @default(now()) @db.Timestamptz(3) - mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade) + executedAt DateTime @default(now()) @db.Timestamptz(3) + + mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade) @@index([mockServerID]) @@index([mockServerID, executedAt]) @@ -291,11 +304,61 @@ model MockServerActivity { action MockServerAction performedBy String? performedAt DateTime @default(now()) @db.Timestamptz(3) - mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade) + + mockServer MockServer @relation(fields: [mockServerID], references: [id], onDelete: Cascade) @@index([mockServerID]) } +// ============================================================ +// OTHERS +// ============================================================ + +model Shortcode { + id String @id @unique + request Json + creatorUid String? + embedProperties Json? + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @default(now()) @updatedAt @db.Timestamptz(3) + + user User? @relation(fields: [creatorUid], references: [uid]) + + @@unique([id, creatorUid], name: "creator_uid_shortcode_unique") +} + +model InfraConfig { + id String @id @default(cuid()) + name String @unique + value String? + isEncrypted Boolean @default(false) + lastSyncedEnvFileValue String? + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) +} + +model PersonalAccessToken { + id String @id @default(cuid()) + userUid String + 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 { + id String @id @default(cuid()) + creatorUid String + label String + token String @unique @default(uuid()) + expiresOn DateTime? @db.Timestamptz(3) + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @default(now()) @db.Timestamptz(3) +} + model PublishedDocs { id String @id @default(cuid()) slug String @@ -317,26 +380,3 @@ model PublishedDocs { @@unique([slug, version]) @@index([collectionID]) } - -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/errors.ts b/packages/hoppscotch-backend/src/errors.ts index fac3473880b..98c749f911c 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -189,6 +189,12 @@ export const TEAM_FB_COLL_PATH_RESOLVE_FAIL = 'team/fb_coll_path_resolve_fail'; */ export const TEAM_COLL_NOT_FOUND = 'team_coll/collection_not_found'; +/** + * The collection does not have the same parent as the expected parent + * (TeamCollectionService) + */ +export const TEAM_COLL_NOT_SAME_PARENT = 'team_coll/not_same_parent'; + /** * Could not find the team in the database * (TeamCollectionService) @@ -221,13 +227,6 @@ export const TEAM_COLL_DEST_SAME = export const TEAM_COL_ALREADY_ROOT = 'team_coll/target_collection_is_already_root_collection'; -/** - * Collections have different parents - * (TeamCollectionService) - */ -export const TEAM_COL_NOT_SAME_PARENT = - 'team_coll/team_collections_have_different_parents'; - /** * Collection and next Collection are the same * (TeamCollectionService) diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts index 7962c696528..48051eb7ce5 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.model.ts @@ -5,7 +5,7 @@ import { ServiceStatus } from './helper'; @ObjectType() export class InfraConfig { - @Field({ + @Field(() => InfraConfigEnum, { description: 'Infra Config Name', }) name: InfraConfigEnum; diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts index 8872bba984b..dcf43143161 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts @@ -93,6 +93,9 @@ export class MockServerController { method, queryParams, requestHeaders, + // Parsed by the global express.json() middleware — needed for + // GraphQL operation-based matching ({query, operationName, variables}) + req.body, ); if (E.isLeft(result)) { 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 index 4f6ba1a7755..0e84b6190b8 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.service.spec.ts @@ -2070,4 +2070,446 @@ describe('MockServerService', () => { expect(result.queryParams).toEqual({}); }); }); + + describe('handleMockRequest — GraphQL', () => { + const gqlExample = { + key: 'gql1', + name: 'Get User Example', + endpoint: null, + method: null, + operationName: 'GetUser', + operationType: 'query', + statusCode: 200, + statusText: 'OK', + responseBody: '{"data":{"user":{"id":"1"}}}', + responseHeaders: [{ key: 'content-type', value: 'application/json' }], + headers: [], + }; + + const gqlUserRequest = { + id: 'gqlreq1', + collectionID: userCollection.id, + teamID: null, + title: 'Get User', + request: {}, + mockExamples: { + examples: [gqlExample], + }, + orderIndex: 1, + createdOn: currentTime, + updatedOn: currentTime, + } as any; + + const setupMocks = (requests: any[]) => { + mockPrisma.userCollection.findUnique.mockResolvedValue(userCollection); + mockPrisma.userCollection.findMany.mockResolvedValue([]); + mockPrisma.userRequest.findMany.mockResolvedValue(requests as any); + }; + + test('matches a GraphQL example by operation name and type', async () => { + setupMocks([gqlUserRequest]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: 'query GetUser { user { id } }' }, + ); + + 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('{"data":{"user":{"id":"1"}}}'); + } + }); + + test('exact operation-name match beats a wildcard example', async () => { + const wildcardExample = { + ...gqlExample, + key: 'gql-wild', + name: 'Any Query', + operationName: null, + responseBody: '{"data":{"wildcard":true}}', + }; + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { examples: [wildcardExample, gqlExample] }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: 'query GetUser { user { id } }' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).body).toBe('{"data":{"user":{"id":"1"}}}'); + } + }); + + test('wildcard example matches an unnamed operation of the same type', async () => { + const wildcardExample = { + ...gqlExample, + key: 'gql-wild', + name: 'Any Query', + operationName: null, + responseBody: '{"data":{"wildcard":true}}', + }; + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { examples: [wildcardExample] }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: '{ user { id } }' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).body).toBe('{"data":{"wildcard":true}}'); + } + }); + + test('operation type mismatch returns Left', async () => { + setupMocks([gqlUserRequest]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: 'mutation GetUser { updateUser { id } }' }, + ); + + expect(E.isLeft(result)).toBe(true); + }); + + test('matches a large query', async () => { + setupMocks([gqlUserRequest]); + + const large = `query GetUser { user { id ${'alias: id '.repeat( + 5_000, + )} } }`; + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: large }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).body).toBe('{"data":{"user":{"id":"1"}}}'); + } + }); + + test('anonymous multi-operation document without operationName returns 400 errors body', async () => { + setupMocks([gqlUserRequest]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { + query: 'query GetUser { user { id } } query Other { other { id } }', + }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(400); + expect((result.right as any).body).toContain('operationName'); + } + }); + + test('multi-operation document with operationName selects the named operation', async () => { + setupMocks([gqlUserRequest]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { + query: 'query GetUser { user { id } } query Other { other { id } }', + operationName: 'GetUser', + }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + } + }); + + test('unparseable query falls through to REST matching', async () => { + const restExample = { + key: 'rest1', + name: 'REST Example', + method: 'POST', + endpoint: 'http://api.example.com/search', + statusCode: 200, + statusText: 'OK', + responseBody: '{"rest":true}', + responseHeaders: [], + headers: [], + }; + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { examples: [restExample] }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/search', + 'POST', + {}, + {}, + { query: 'SELECT * FROM users' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).body).toBe('{"rest":true}'); + } + }); + + // Regression: operation stamping only exists in clients shipping the + // unified playground, so every example in an already-deployed database is + // unstamped. A parseable GraphQL body must not divert into operation + // matching when the mock owns no GraphQL examples — doing so 404s every + // pre-existing REST mock of a GraphQL-over-HTTP endpoint. + const unstampedGraphqlRestExample = { + key: 'rest-gql', + name: 'Legacy GraphQL Mock', + method: 'POST', + endpoint: 'http://api.example.com/graphql', + statusCode: 200, + statusText: 'OK', + responseBody: '{"data":{"legacy":true}}', + responseHeaders: [], + headers: [], + }; + + test('parseable GraphQL body falls through to REST when no example is operation-stamped', async () => { + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { examples: [unstampedGraphqlRestExample] }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: 'query GetUser { user { id } }' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).body).toBe('{"data":{"legacy":true}}'); + } + }); + + test('x-mock-response-name still resolves when no example is operation-stamped', async () => { + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { examples: [unstampedGraphqlRestExample] }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + { 'x-mock-response-name': 'Legacy GraphQL Mock' }, + { query: 'query GetUser { user { id } }' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).body).toBe('{"data":{"legacy":true}}'); + } + }); + + test('GraphQL operation with no matching stamped example falls back to REST scoring', async () => { + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { + // A stamped mutation example plus a plain REST example on the same + // path — an unmatched *query* must reach the REST example, not 404 + examples: [ + { ...gqlExample, operationType: 'mutation', operationName: 'Nope' }, + unstampedGraphqlRestExample, + ], + }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: 'query GetUser { user { id } }' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).body).toBe('{"data":{"legacy":true}}'); + } + }); + + test('x-mock-response-name override picks the named GraphQL example', async () => { + const otherExample = { + ...gqlExample, + key: 'gql2', + name: 'Error Case', + statusCode: 500, + responseBody: '{"errors":[{"message":"boom"}]}', + }; + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { examples: [gqlExample, otherExample] }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + { 'x-mock-response-name': 'Error Case' }, + { query: 'query GetUser { user { id } }' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(500); + } + }); + + test('x-mock-response-name override does not cross operation types', async () => { + // A query example and a mutation example share the name 'Shared Name'. + // A mutation request naming it must not resolve to the query example. + const queryNamed = { + ...gqlExample, + key: 'gql-q', + name: 'Shared Name', + operationType: 'query', + responseBody: '{"data":{"fromQuery":true}}', + }; + const mutationNamed = { + ...gqlExample, + key: 'gql-m', + name: 'Shared Name', + operationName: 'UpdateUser', + operationType: 'mutation', + responseBody: '{"data":{"fromMutation":true}}', + }; + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { examples: [queryNamed, mutationNamed] }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + { 'x-mock-response-name': 'Shared Name' }, + { query: 'mutation UpdateUser { updateUser { id } }' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).body).toBe( + '{"data":{"fromMutation":true}}', + ); + } + }); + + test('treats an empty-string operationName as unspecified', async () => { + setupMocks([gqlUserRequest]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: 'query GetUser { user { id } }', operationName: '' }, + ); + + 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('{"data":{"user":{"id":"1"}}}'); + } + }); + + test('prefers a 200 example among equal-score matches', async () => { + const error500 = { + ...gqlExample, + key: 'gql-a-500', + name: 'Failure', + statusCode: 500, + responseBody: '{"errors":[{"message":"boom"}]}', + }; + setupMocks([ + { + ...gqlUserRequest, + mockExamples: { examples: [error500, gqlExample] }, + }, + ]); + + const result = await mockServerService.handleMockRequest( + dbMockServer, + '/graphql', + 'POST', + {}, + {}, + { query: 'query GetUser { user { id } }' }, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect((result.right as any).statusCode).toBe(200); + } + }); + }); }); diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts index 6efbb8a4e15..7578cfd5699 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts @@ -21,6 +21,7 @@ import { MOCK_SERVER_COLLECTION_CREATION_FAILED, } from 'src/errors'; import { randomBytes } from 'crypto'; +import { parse, getOperationAST } from 'graphql'; import { WorkspaceType } from 'src/types/WorkspaceTypes'; import { MockServerAction, @@ -37,6 +38,23 @@ import { ReqType } from 'src/types/RequestTypes'; import { AuthUser } from 'src/types/AuthUser'; import { mockServerCollRequestExample } from './constants/mock-server-coll-request-example'; +/** + * A saved example carrying GraphQL operation identity, projected by the + * `sync_mock_examples` trigger. Examples saved before operation stamping + * existed (i.e. every example in an already-deployed database) have no + * `operationType` and are not GraphQL examples. + */ +interface GqlExample { + id: string; + name: string; + operationName: string | null; + operationType: string; + statusCode: number; + statusText: string; + responseBody: string; + responseHeaders: Array<{ key: string; value: string }>; +} + @Injectable() export class MockServerService { constructor( @@ -714,6 +732,7 @@ export class MockServerService { method: string, queryParams?: Record, requestHeaders?: Record, + requestBody?: unknown, ): Promise> { try { // OPTIMIZATION: Fetch collection IDs once (recursive DB query) @@ -733,6 +752,56 @@ export class MockServerService { collectionIds, ); + // GraphQL branch: a body that parses as a GraphQL document routes to + // operation-based matching instead of path scoring. A body that merely + // LOOKS GraphQL-ish but fails to parse falls through to REST matching, + // so REST payloads that happen to carry a `query` field keep working. + // + // The branch is gated on this mock actually owning GraphQL-stamped + // examples. Operation stamping only exists in clients that ship the + // unified playground, so every example in an already-deployed database + // is unstamped — without the gate, a mock that has always served + // `POST /graphql` from a REST example would start returning 404 on + // upgrade, with no way back (the divert precedes the + // `x-mock-response-id`/`-name` override below). + // + // The example gate is evaluated FIRST, and deliberately so: collecting + // examples is an in-memory pass over rows already fetched above, whereas + // parsing allocates an AST several times the size of its input. This + // endpoint is public (throttled only), so a mock that could not use the + // result should never reach the parser. + const gqlExamples = this.collectGraphQLExamples(requests); + if (gqlExamples.length > 0) { + const gqlOperation = this.resolveGraphQLOperation(requestBody); + + if (gqlOperation) { + if (E.isLeft(gqlOperation)) { + // Genuinely GraphQL (parsed) but unresolvable operation — + // respond per GraphQL-over-HTTP instead of a REST-style 404 + return E.right({ + statusCode: 400, + body: JSON.stringify({ + errors: [{ message: gqlOperation.left }], + }), + headers: JSON.stringify({ 'content-type': 'application/json' }), + delay: mockServer.delayInMs || 0, + }); + } + + const gqlResult = this.handleGraphQLMockRequest( + gqlOperation.right, + gqlExamples, + requestHeaders, + mockServer.delayInMs, + ); + + // A mixed collection can hold GraphQL examples that don't cover this + // operation while still holding a REST example for the same path, so + // a miss falls through to REST scoring rather than terminating in 404. + if (E.isRight(gqlResult)) return gqlResult; + } + } + // OPTIMIZATION: Check for custom headers first (fastest path) // If user specified exact example, return it immediately without scoring if (requestHeaders) { @@ -1175,6 +1244,153 @@ export class MockServerService { /** * Format example response for return */ + /** + * Resolve the executed GraphQL operation from a request body. + * + * Returns `null` when the body is not a GraphQL payload (missing/non-string + * `query`, or a `query` that fails GraphQL parsing — the latter deliberately + * falls through to REST matching so REST payloads carrying a `query` field + * are never misclassified). Returns `Left` only for genuinely-GraphQL + * documents whose operation can't be resolved (anonymous multi-operation + * documents without an `operationName`). + */ + private resolveGraphQLOperation( + body: unknown, + ): null | E.Either { + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; + + const query = (body as Record).query; + if (typeof query !== 'string' || query.trim().length === 0) return null; + + let doc; + try { + doc = parse(query); + } catch (_e) { + return null; + } + + const rawOpName = (body as Record).operationName; + const node = getOperationAST( + doc, + typeof rawOpName === 'string' && rawOpName.length > 0 + ? rawOpName + : undefined, + ); + if (!node) { + return E.left( + 'Must provide operationName if query contains multiple operations.', + ); + } + + return E.right({ name: node.name?.value ?? null, type: node.operation }); + } + + /** + * Operation-based matching for GraphQL mock requests — the GraphQL + * counterpart of the path/query scoring pipeline. GraphQL examples are + * discriminated by SHAPE (presence of `operationType`, stamped by the app + * and projected by the `sync_mock_examples` trigger). Team requests carry + * no REST/GQL discriminator of their own, so shape is the only signal: + * GraphQL requests live alongside REST ones in the same rows by design. + */ + private collectGraphQLExamples( + requests: Array<{ id: string; mockExamples: any }>, + ): GqlExample[] { + const examples: GqlExample[] = []; + for (const request of requests) { + const mockExamples = request.mockExamples as any; + if (!Array.isArray(mockExamples?.examples)) continue; + for (const exampleData of mockExamples.examples) { + if (typeof exampleData?.operationType !== 'string') continue; + examples.push({ + id: exampleData.key || `${request.id}-${exampleData.name}`, + name: exampleData.name || '', + operationName: exampleData.operationName || null, + operationType: exampleData.operationType, + statusCode: exampleData.statusCode || 200, + statusText: exampleData.statusText || 'OK', + responseBody: exampleData.responseBody || '', + responseHeaders: Array.isArray(exampleData.responseHeaders) + ? exampleData.responseHeaders + : [], + }); + } + } + return examples; + } + + private handleGraphQLMockRequest( + operation: { name: string | null; type: string }, + examples: GqlExample[], + requestHeaders: Record | undefined, + delayInMs: number, + ): E.Either { + if (examples.length === 0) { + return E.left( + `No GraphQL mock examples found for ${operation.type} ${operation.name ?? '(anonymous)'}`, + ); + } + + // x-mock-response-id / x-mock-response-name: exact-example override, + // bypassing scoring. The REST fast path gates its override on the HTTP + // method; over GraphQL the method carries no meaning, so the operation + // type is the equivalent discriminator — a mutation request must never + // resolve to a query example just because the names collide. + const overrideId = requestHeaders?.['x-mock-response-id']; + const overrideName = requestHeaders?.['x-mock-response-name']; + if (overrideId || overrideName) { + const exact = examples.find( + (ex) => + ex.operationType === operation.type && + ((overrideId && ex.id === overrideId) || + (overrideName && ex.name === overrideName)), + ); + if (exact) return this.formatExampleResponse(exact, delayInMs); + } + + // x-mock-response-code: status pre-filter with silent fallback (REST parity) + let filtered = examples; + const overrideCode = requestHeaders?.['x-mock-response-code']; + if (overrideCode) { + const statusCode = parseInt(overrideCode, 10); + const codeFiltered = examples.filter( + (ex) => ex.statusCode === statusCode, + ); + if (codeFiltered.length > 0) filtered = codeFiltered; + } + + // Scoring: operation type must match; exact operation-name match beats a + // wildcard example (one saved without an operation name); anything else + // is a non-match. Anonymous request operations match only wildcards. + const scored = filtered + .map((example) => { + if (example.operationType !== operation.type) + return { example, score: 0 }; + if (example.operationName === operation.name) + return { example, score: 100 }; + if (!example.operationName) return { example, score: 95 }; + return { example, score: 0 }; + }) + .filter((s) => s.score > 0) + .sort((a, b) => b.score - a.score); + + if (scored.length === 0) { + return E.left( + `No matching GraphQL mock example for ${operation.type} ${operation.name ?? '(anonymous)'}`, + ); + } + + // Deterministic selection among ties: prefer 200s (REST parity), then + // lexicographic example id — unlike REST, never DB row order + const highest = scored[0].score; + const top = scored + .filter((s) => s.score === highest) + .sort((a, b) => a.example.id.localeCompare(b.example.id)); + const selected = top.find((s) => s.example.statusCode === 200) ?? top[0]; + + return this.formatExampleResponse(selected.example, delayInMs); + } + private formatExampleResponse( example: any, delayInMs: number, diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts index 635312a8ec3..f8295c5531a 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.model.ts @@ -60,7 +60,7 @@ export class ShortcodeWithUserEmail { }) createdOn: Date; - @Field({ + @Field(() => ShortcodeCreator, { description: 'Details of user who created the shortcode', nullable: true, }) diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts index 672dfd86c68..b76dc9b0375 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts @@ -99,7 +99,7 @@ const shortcodesWithUserEmail = [ creatorUid: user.uid, createdOn: new Date(), updatedOn: createdOn, - User: user, + user: user, }, { id: 'blablabla1', @@ -112,7 +112,7 @@ const shortcodesWithUserEmail = [ creatorUid: user.uid, createdOn: new Date(), updatedOn: createdOn, - User: user, + user: user, }, ]; diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts index 70a59c5e902..f230f7d5102 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.ts @@ -298,7 +298,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { const shortCodes = await this.prisma.shortcode.findMany({ where: userEmail ? { - User: { + user: { email: { equals: userEmail, mode: 'insensitive', @@ -313,7 +313,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { take: args.take, cursor: args.cursor ? { id: args.cursor } : undefined, include: { - User: true, + user: true, }, }); @@ -327,10 +327,10 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit { ? JSON.stringify(code.embedProperties) : null, createdOn: code.createdOn, - creator: code.User + creator: code.user ? { - uid: code.User.uid, - email: code.User.email, + uid: code.user.uid, + email: code.user.email, } : null, }; diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.model.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.model.ts index d34a290f888..1c497523554 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.model.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.model.ts @@ -27,12 +27,12 @@ export class TeamCollection { @ObjectType() export class CollectionReorderData { - @Field({ + @Field(() => TeamCollection, { description: 'Team Collection being moved', }) collection: TeamCollection; - @Field({ + @Field(() => TeamCollection, { description: 'Team Collection succeeding the collection being moved in its new position', nullable: true, diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts index 25e89dcaa56..7a559d8b0ba 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts @@ -164,12 +164,12 @@ export class TeamCollectionResolver { type: () => ID, }) collectionID: string, - ) { + ): Promise { const teamCollections = await this.teamCollectionService.getCollection(collectionID); if (E.isLeft(teamCollections)) throwErr(teamCollections.left); - return { + return { id: teamCollections.right.id, title: teamCollections.right.title, parentID: teamCollections.right.parentID, diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts index 8488da37b7d..e109a4f875f 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts @@ -9,6 +9,7 @@ import { TEAM_COLL_INVALID_JSON, TEAM_COLL_IS_PARENT_COLL, TEAM_COLL_NOT_FOUND, + TEAM_COLL_NOT_SAME_PARENT, TEAM_COLL_NOT_SAME_TEAM, TEAM_COLL_SHORT_TITLE, TEAM_COL_ALREADY_ROOT, @@ -675,9 +676,7 @@ describe('createCollection', () => { }); test('should throw TEAM_NOT_OWNER when parent TeamCollection does not belong to the team', async () => { - jest - .spyOn(teamCollectionService as any, 'isOwnerCheck') - .mockResolvedValueOnce(O.none); + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); const result = await teamCollectionService.createCollection( rootTeamCollection.teamID, @@ -688,10 +687,10 @@ describe('createCollection', () => { expect(result).toEqualLeft(TEAM_NOT_OWNER); }); - test('should throw TEAM_COLL_DATA_INVALID when parent TeamCollection does not belong to the team', async () => { - jest - .spyOn(teamCollectionService as any, 'isOwnerCheck') - .mockResolvedValueOnce(O.some(true)); + test('should throw TEAM_COLL_DATA_INVALID when the data is invalid JSON', async () => { + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce({ + ...rootTeamCollection, + }); const result = await teamCollectionService.createCollection( rootTeamCollection.teamID, @@ -720,9 +719,10 @@ describe('createCollection', () => { }); test('should successfully create a new child TeamCollection with valid inputs', async () => { - jest - .spyOn(teamCollectionService as any, 'isOwnerCheck') - .mockResolvedValueOnce(O.some(true)); + // parent ownership check + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce({ + ...rootTeamCollection, + }); mockPrisma.$transaction.mockImplementationOnce(async (fn) => fn(mockPrisma), ); @@ -740,9 +740,10 @@ describe('createCollection', () => { }); test('should send pubsub message to "team_coll//coll_added" if child TeamCollection is created successfully', async () => { - jest - .spyOn(teamCollectionService as any, 'isOwnerCheck') - .mockResolvedValueOnce(O.some(true)); + // parent ownership check + mockPrisma.teamCollection.findFirst.mockResolvedValueOnce({ + ...rootTeamCollection, + }); mockPrisma.$transaction.mockImplementationOnce(async (fn) => fn(mockPrisma), ); @@ -1338,6 +1339,33 @@ describe('updateCollectionOrder', () => { expect(result).toEqualLeft(TEAM_COLL_NOT_SAME_TEAM); }); + test('should throw TEAM_COLL_NOT_SAME_PARENT if collection and nextCollection have different parents', async () => { + // getCollection; both collections belong to the same team but sit under + // different parents, so reordering between them is not a valid operation + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(childTeamCollectionList[4]) + .mockResolvedValueOnce(childTeamCollection_2); + + const result = await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + childTeamCollection_2.id, + ); + expect(result).toEqualLeft(TEAM_COLL_NOT_SAME_PARENT); + }); + + test('should not reorder when collection and nextCollection have different parents', async () => { + mockPrisma.teamCollection.findUniqueOrThrow + .mockResolvedValueOnce(childTeamCollectionList[4]) + .mockResolvedValueOnce(childTeamCollection_2); + + await teamCollectionService.updateCollectionOrder( + childTeamCollectionList[4].id, + childTeamCollection_2.id, + ); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + expect(mockPubSub.publish).not.toHaveBeenCalled(); + }); + test('should successfully update the order of the child TeamCollection list', async () => { // getCollection; mockPrisma.teamCollection.findUniqueOrThrow @@ -1451,6 +1479,21 @@ describe('importCollectionsFromJSON', () => { expect(result).toEqualLeft(TEAM_COLL_INVALID_JSON); }); + test('should throw TEAM_NOT_OWNER when the parent collection does not belong to the team', async () => { + // getCollection (parent lookup) + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce({ + ...rootTeamCollection, + teamID: 'another-team-id', + }); + + const result = await teamCollectionService.importCollectionsFromJSON( + jsonString, + rootTeamCollection.teamID, + rootTeamCollection.id, + ); + expect(result).toEqualLeft(TEAM_NOT_OWNER); + }); + test('should successfully create new TeamCollections in root and TeamRequests with valid inputs', async () => { mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); @@ -1465,6 +1508,10 @@ describe('importCollectionsFromJSON', () => { }); test('should successfully create new TeamCollections in a child collection and TeamRequests with valid inputs', async () => { + // getCollection (parent lookup) + mockPrisma.teamCollection.findUniqueOrThrow.mockResolvedValueOnce({ + ...rootTeamCollection, + }); mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); mockPrisma.teamCollection.findFirst.mockResolvedValueOnce(null); mockPrisma.teamCollection.create.mockResolvedValueOnce(rootTeamCollection); 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 40a98e76dfa..95d915ae5cf 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -20,6 +20,7 @@ import { TEAM_COLL_PARENT_TREE_GEN_FAILED, TEAM_MEMBER_NOT_FOUND, TEAM_COLL_CREATION_FAILED, + TEAM_COLL_NOT_SAME_PARENT, } from '../errors'; import { PubSubService } from '../pubsub/pubsub.service'; import { @@ -210,6 +211,15 @@ export class TeamCollectionService { if (!Array.isArray(collectionsList.right)) return E.left(TEAM_COLL_INVALID_JSON); + // When importing into an existing parent, ensure the parent belongs to + // the team + if (parentID) { + const parentCollection = await this.getCollection(parentID); + if (E.isLeft(parentCollection)) return E.left(TEAM_COLL_NOT_FOUND); + if (parentCollection.right.teamID !== teamID) + return E.left(TEAM_NOT_OWNER); + } + let teamCollections: DBTeamCollection[] = []; let queryList: Prisma.TeamCollectionCreateInput[] = []; try { @@ -279,7 +289,7 @@ export class TeamCollectionService { private cast(teamCollection: DBTeamCollection): TeamCollection { const data = transformCollectionData(teamCollection.data); - return { + return { id: teamCollection.id, title: teamCollection.title, parentID: teamCollection.parentID, @@ -419,28 +429,6 @@ export class TeamCollectionService { } } - /** - * Check to see if Collection belongs to Team - * - * @param collectionID getChildCollectionsCount - * @param teamID The Team ID - * @returns An Option of a Boolean - */ - private async isOwnerCheck(collectionID: string, teamID: string) { - try { - await this.prisma.teamCollection.findFirstOrThrow({ - where: { - id: collectionID, - teamID, - }, - }); - - return O.some(true); - } catch (error) { - return O.none; - } - } - /** * Create a new TeamCollection * @@ -458,10 +446,13 @@ export class TeamCollectionService { const isTitleValid = isValidLength(title, this.TITLE_LENGTH); if (!isTitleValid) return E.left(TEAM_COLL_SHORT_TITLE); - // Check to see if parentTeamCollectionID belongs to this Team + // Check that the parent collection belongs to this Team if (parentID !== null) { - const isOwner = await this.isOwnerCheck(parentID, teamID); - if (O.isNone(isOwner)) return E.left(TEAM_NOT_OWNER); + const parentCollection = await this.prisma.teamCollection.findFirst({ + where: { id: parentID, teamID }, + select: { id: true }, + }); + if (!parentCollection) return E.left(TEAM_NOT_OWNER); } if (data === '') return E.left(TEAM_COLL_DATA_INVALID); @@ -976,6 +967,10 @@ export class TeamCollectionService { if (collection.right.teamID !== subsequentCollection.right.teamID) return E.left(TEAM_COLL_NOT_SAME_TEAM); + // Check if collection and subsequentCollection have the same parentID + if (collection.right.parentID !== subsequentCollection.right.parentID) + return E.left(TEAM_COLL_NOT_SAME_PARENT); + try { await this.prisma.$transaction(async (tx) => { try { diff --git a/packages/hoppscotch-backend/src/team-request/team-request.model.ts b/packages/hoppscotch-backend/src/team-request/team-request.model.ts index 79363b4b5fc..ce93ee3f09d 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.model.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.model.ts @@ -30,12 +30,12 @@ export class TeamRequest { @ObjectType() export class RequestReorderData { - @Field({ + @Field(() => TeamRequest, { description: 'Team Request being moved', }) request: TeamRequest; - @Field({ + @Field(() => TeamRequest, { description: 'Team Request succeeding the request being moved in its new position', nullable: true, 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 3814844d099..d6baef9331c 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 @@ -264,6 +264,7 @@ describe('createTeamRequest', () => { jest .spyOn(mockTeamCollectionService, 'getTeamOfCollection') .mockResolvedValue(E.right(team)); + mockPrisma.teamCollection.findUnique.mockResolvedValue(teamCollection); mockPrisma.$transaction.mockImplementation(async (fn) => { return fn(mockPrisma); }); @@ -271,13 +272,13 @@ describe('createTeamRequest', () => { mockPrisma.teamRequest.create.mockResolvedValue(dbRequest); const response = teamRequestService.createTeamRequest( - teamRequest.title, + teamCollection.id, team.id, teamRequest.title, teamRequest.request, ); - expect(response).resolves.toEqualRight(teamRequest); + await expect(response).resolves.toEqualRight(teamRequest); }); test('publishes creation to pubsub topic "team_req//req_created"', async () => { @@ -287,6 +288,7 @@ describe('createTeamRequest', () => { jest .spyOn(mockTeamCollectionService, 'getTeamOfCollection') .mockResolvedValue(E.right(team)); + mockPrisma.teamCollection.findUnique.mockResolvedValue(teamCollection); mockPrisma.$transaction.mockImplementation(async (fn) => { return fn(mockPrisma); }); @@ -294,7 +296,7 @@ describe('createTeamRequest', () => { mockPrisma.teamRequest.create.mockResolvedValue(dbRequest); await teamRequestService.createTeamRequest( - teamRequest.title, + teamCollection.id, team.id, teamRequest.title, teamRequest.request, @@ -527,6 +529,7 @@ describe('findRequestAndNextRequest', () => { mockPrisma.teamRequest.findFirst .mockResolvedValueOnce(dbTeamRequests[0]) .mockResolvedValueOnce(dbTeamRequests[4]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(teamCollection); const result = await (teamRequestService as any).findRequestAndNextRequest( args.srcCollID, @@ -607,6 +610,54 @@ describe('findRequestAndNextRequest', () => { expect(result).toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID); }); + test('Should resolve left if the destination collection does not exist when nextRequestID is given', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: 'non-existent-coll', + requestID: teamRequests[0].id, + nextRequestID: teamRequests[4].id, + }; + + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(dbTeamRequests[0]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = await (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).toEqualLeft(TEAM_INVALID_COLL_ID); + // The destination is validated before the nextRequest lookup, so only the + // request itself should have been fetched + expect(mockPrisma.teamRequest.findFirst).toHaveBeenCalledTimes(1); + }); + test('Should resolve left if the destination collection belongs to a different team when nextRequestID is given', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: 'cross-team-coll', + requestID: teamRequests[0].id, + nextRequestID: teamRequests[4].id, + }; + + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(dbTeamRequests[0]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + ...teamCollection, + id: 'cross-team-coll', + teamID: 'different-team-id', + }); + + const result = await (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID); + expect(mockPrisma.teamRequest.findFirst).toHaveBeenCalledTimes(1); + }); test('Should resolve left if the request is not found', () => { const args: MoveTeamRequestArgs = { srcCollID: teamRequests[0].collectionID, @@ -637,6 +688,7 @@ describe('findRequestAndNextRequest', () => { mockPrisma.teamRequest.findFirst .mockResolvedValueOnce(dbTeamRequests[0]) .mockResolvedValueOnce(null); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(teamCollection); const result = (teamRequestService as any).findRequestAndNextRequest( args.srcCollID, diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.ts index 0faf18bc56e..a43f58c0ccd 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.ts @@ -35,7 +35,7 @@ export class TeamRequestService { * A helper function to cast the Prisma TeamRequest model to the TeamRequest model * @param tr TeamRequest model from Prisma */ - private cast(tr: DbTeamRequest) { + private cast(tr: DbTeamRequest): TeamRequest { return { id: tr.id, collectionID: tr.collectionID, @@ -388,6 +388,17 @@ export class TeamRequestService { }); if (!request) return E.left(TEAM_REQ_NOT_FOUND); + // The destination collection must exist and belong to the same team as + // the request + const destCollection = await this.prisma.teamCollection.findUnique({ + where: { id: destCollID }, + select: { teamID: true }, + }); + if (!destCollection) return E.left(TEAM_INVALID_COLL_ID); + if (destCollection.teamID !== request.teamID) { + return E.left(TEAM_REQ_INVALID_TARGET_COLL_ID); + } + let nextRequest = null; if (nextRequestID) { nextRequest = await this.prisma.teamRequest.findFirst({ @@ -401,18 +412,6 @@ export class TeamRequestService { ) { return E.left(TEAM_REQ_INVALID_TARGET_COLL_ID); } - } else { - // When nextRequestID is null, validate that the destination collection - // belongs to the same team as the request to prevent cross-team moves - const destCollection = await this.prisma.teamCollection.findUnique({ - where: { id: destCollID }, - select: { teamID: true }, - }); - if (!destCollection) return E.left(TEAM_INVALID_COLL_ID); - - if (destCollection.teamID !== request.teamID) { - return E.left(TEAM_REQ_INVALID_TARGET_COLL_ID); - } } return E.right({ request, nextRequest }); diff --git a/packages/hoppscotch-backend/src/types/RequestTypes.ts b/packages/hoppscotch-backend/src/types/RequestTypes.ts index 717db6ac7be..2485251b54a 100644 --- a/packages/hoppscotch-backend/src/types/RequestTypes.ts +++ b/packages/hoppscotch-backend/src/types/RequestTypes.ts @@ -1,4 +1,10 @@ +import { registerEnumType } from '@nestjs/graphql'; + export enum ReqType { REST = 'REST', GQL = 'GQL', } + +registerEnumType(ReqType, { + name: 'ReqType', +}); diff --git a/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts b/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts index c7947c97aba..1caf8942122 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collections.model.ts @@ -32,12 +32,12 @@ export class UserCollection { @ObjectType() export class UserCollectionReorderData { - @Field({ + @Field(() => UserCollection, { description: 'User Collection being moved', }) userCollection: UserCollection; - @Field({ + @Field(() => UserCollection, { description: 'User Collection succeeding the collection being moved in its new position', nullable: true, diff --git a/packages/hoppscotch-backend/src/user-history/user-history.model.ts b/packages/hoppscotch-backend/src/user-history/user-history.model.ts index c384391bcd3..71c420fe793 100644 --- a/packages/hoppscotch-backend/src/user-history/user-history.model.ts +++ b/packages/hoppscotch-backend/src/user-history/user-history.model.ts @@ -1,4 +1,4 @@ -import { Field, ID, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { Field, ID, ObjectType } from '@nestjs/graphql'; import { ReqType } from 'src/types/RequestTypes'; @ObjectType() @@ -51,7 +51,3 @@ export class UserHistoryDeletedManyData { }) reqType: ReqType; } - -registerEnumType(ReqType, { - name: 'ReqType', -}); diff --git a/packages/hoppscotch-backend/src/user-request/user-request.model.ts b/packages/hoppscotch-backend/src/user-request/user-request.model.ts index eb2987fdcc8..5cf17ac1989 100644 --- a/packages/hoppscotch-backend/src/user-request/user-request.model.ts +++ b/packages/hoppscotch-backend/src/user-request/user-request.model.ts @@ -36,12 +36,12 @@ export class UserRequest { @ObjectType() export class UserRequestReorderData { - @Field({ + @Field(() => UserRequest, { description: 'User request being moved', }) request: UserRequest; - @Field({ + @Field(() => UserRequest, { description: 'User request succeeding the request being moved in its new position', nullable: true, diff --git a/packages/hoppscotch-cli/README.md b/packages/hoppscotch-cli/README.md index 8a36a42027b..f3e9e611702 100644 --- a/packages/hoppscotch-cli/README.md +++ b/packages/hoppscotch-cli/README.md @@ -35,6 +35,35 @@ hopp [options or commands] arguments - Outputs the response of each request. - Executes and outputs test-script response. + #### GraphQL support: + + Collections can freely mix REST and GraphQL requests — both run in + collection order through the same pipeline (environment templating, + the full auth surface, pre-request/test scripts, collection-level + script inheritance, metrics, and reports). + + - GraphQL requests execute as GraphQL-over-HTTP: a `POST` with a JSON + `{"query", "variables", "operationName"}` body. + - The **first operation** in the document runs; for multi-operation + documents its name is injected as `operationName` automatically. + - Environment variables resolve anywhere in the request — URL, headers, + the query (including a whole document stored in a variable), and + **inside `variables`**, including bare non-string positions like + `{ "count": <> }`. Invalid `variables` JSON fails the request with + an error instead of sending a broken body. + - Test scripts assert on the GraphQL response body as parsed JSON, e.g. + `pw.expect(pw.response.body.data.hello).toBe("world")`. + - **Subscriptions cannot run** in a single HTTP round-trip: they are + reported as request errors, the run continues, and the exit code is + non-zero (matching the app's collection runner). + + Runnable examples live in + [`src/__tests__/e2e/fixtures/`](./src/__tests__/e2e/fixtures/): + `collections/mixed-rest-gql-coll.json` (REST + GraphQL, run with + `--env environments/gql-envs.json`) and `collections/gql-coll.json` + (GraphQL only). They run in CI against the public echo server at + `https://echo.hoppscotch.io/graphql`. + #### Options: ##### `-e, --env ` diff --git a/packages/hoppscotch-cli/package.json b/packages/hoppscotch-cli/package.json index 3b996d35352..e861ad305d7 100644 --- a/packages/hoppscotch-cli/package.json +++ b/packages/hoppscotch-cli/package.json @@ -1,6 +1,6 @@ { "name": "@hoppscotch/cli", - "version": "0.31.3", + "version": "0.31.4", "description": "A CLI to run Hoppscotch test scripts in CI environments.", "homepage": "https://hoppscotch.io", "type": "module", @@ -42,15 +42,16 @@ "private": false, "dependencies": { "aws4fetch": "1.0.20", - "axios": "1.18.1", + "axios": "1.19.0", "axios-cookiejar-support": "6.0.5", "chalk": "5.6.2", "commander": "14.0.3", + "graphql": "16.13.2", "isolated-vm": "6.1.2", "js-md5": "0.8.3", "jsonc-parser": "3.3.1", "lodash-es": "4.18.1", - "papaparse": "5.5.4", + "papaparse": "5.6.0", "qs": "6.15.3", "semver": "7.8.5", "tough-cookie": "6.0.2", @@ -66,9 +67,9 @@ "@types/papaparse": "5.5.2", "@types/qs": "6.15.1", "fp-ts": "2.16.11", - "prettier": "3.8.5", + "prettier": "3.9.6", "tsup": "8.5.1", "typescript": "5.9.3", - "vitest": "4.1.10" + "vitest": "4.1.11" } } diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts index 48b80b6652b..4b7a7903615 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -1514,4 +1514,33 @@ describe("hopp test [options] ", { timeout: 100000 }, () => { expect(result.error).toBeNull(); }); }); + + describe("Test `hopp test ` command with GraphQL requests:", () => { + test("Successfully runs an all-GraphQL collection, including pre-request scripts and header templating", async () => { + const args = `test ${getTestJsonFilePath("gql-coll.json", "collection")}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + // Assert content so a regression back to skip-GraphQL can't pass + expect(result.stdout).toContain("echoes the POST method"); + expect(result.stdout).toContain("scripted header echoed"); + expect(result.error).toBeNull(); + }); + + test("Successfully runs a collection mixing REST and GraphQL requests, resolving environment variables from the supplied env file", async () => { + const COLL_PATH = getTestJsonFilePath( + "mixed-rest-gql-coll.json", + "collection" + ); + const ENV_PATH = getTestJsonFilePath("gql-envs.json", "environment"); + const args = `test ${COLL_PATH} --env ${ENV_PATH}`; + const result = await runCLIWithNetworkRetry(args); + if (result === null) return; + + expect(result.stdout).toContain("REST responds 200"); + expect(result.stdout).toContain("env templated header echoed"); + expect(result.stdout).toContain("anonymous query in nested folder"); + expect(result.error).toBeNull(); + }); + }); }); diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/gql-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/gql-coll.json new file mode 100644 index 00000000000..d78ab63037a --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/gql-coll.json @@ -0,0 +1,50 @@ +[ + { + "v": 1, + "name": "GraphQL Only", + "folders": [], + "requests": [ + { + "v": 10, + "name": "Named query", + "url": "https://echo.hoppscotch.io/graphql", + "headers": [], + "query": "query RequestInfo { method url }", + "variables": "{}", + "auth": { "authType": "none", "authActive": true }, + "description": null, + "responses": {}, + "preRequestScript": "", + "testScript": "pw.test(\"echoes the POST method\", () => { pw.expect(pw.response.body.data.method).toBe(\"POST\") })" + }, + { + "v": 10, + "name": "Scripted auth header", + "url": "https://echo.hoppscotch.io/graphql", + "headers": [ + { "key": "Authorization", "value": "Bearer <>", "active": true, "description": "" } + ], + "query": "query AuthEcho { headers { key value } }", + "variables": "{}", + "auth": { "authType": "none", "authActive": true }, + "description": null, + "responses": {}, + "preRequestScript": "pw.env.set(\"myToken\", \"secret123\")", + "testScript": "pw.test(\"scripted header echoed\", () => { const auth = pw.response.body.data.headers.find((h) => h.key === \"authorization\"); pw.expect(auth.value).toBe(\"Bearer secret123\") })" + }, + { + "v": 10, + "name": "Anonymous query", + "url": "https://echo.hoppscotch.io/graphql", + "headers": [], + "query": "{ url }", + "variables": "{}", + "auth": { "authType": "none", "authActive": true }, + "description": null, + "responses": {}, + "preRequestScript": "", + "testScript": "" + } + ] + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/mixed-rest-gql-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/mixed-rest-gql-coll.json new file mode 100644 index 00000000000..3e10644f6e3 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/mixed-rest-gql-coll.json @@ -0,0 +1,57 @@ +[ + { + "v": 1, + "name": "Mixed REST GQL Suite", + "folders": [ + { + "v": 1, + "name": "Inherited", + "folders": [], + "requests": [ + { + "v": 10, + "name": "GQL nested in folder", + "url": "https://echo.hoppscotch.io/graphql", + "headers": [], + "query": "{ method }", + "variables": "{}", + "auth": { "authType": "none", "authActive": true }, + "description": null, + "responses": {}, + "preRequestScript": "", + "testScript": "pw.test(\"anonymous query in nested folder\", () => { pw.expect(pw.response.body.data.method).toBe(\"POST\") })" + } + ] + } + ], + "requests": [ + { + "v": "1", + "name": "REST echo", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "", + "testScript": "pw.test(\"REST responds 200\", () => { pw.expect(pw.response.status).toBe(200); pw.expect(pw.response.body.method).toBe(\"GET\") })", + "auth": { "authType": "none", "authActive": true }, + "body": { "contentType": null, "body": null } + }, + { + "v": 10, + "name": "GQL env templated header", + "url": "https://echo.hoppscotch.io/graphql", + "headers": [ + { "key": "x-hopp-greeting", "value": "<>", "active": true, "description": "" } + ], + "query": "query GreetingEcho { headers { key value } }", + "variables": "{}", + "auth": { "authType": "none", "authActive": true }, + "description": null, + "responses": {}, + "preRequestScript": "", + "testScript": "pw.test(\"env templated header echoed\", () => { const greeting = pw.response.body.data.headers.find((h) => h.key === \"x-hopp-greeting\"); pw.expect(greeting.value).toBe(\"hello-world\") })" + } + ] + } +] diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/gql-envs.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/gql-envs.json new file mode 100644 index 00000000000..d9fbaef32a9 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/environments/gql-envs.json @@ -0,0 +1,13 @@ +{ + "id": "gql-envs", + "v": 2, + "name": "gql-envs", + "variables": [ + { + "key": "greeting", + "initialValue": "hello-world", + "currentValue": "hello-world", + "secret": false + } + ] +} diff --git a/packages/hoppscotch-cli/src/__tests__/unit/collection-fixtures.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/collection-fixtures.spec.ts new file mode 100644 index 00000000000..6b356f1439b --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/collection-fixtures.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "vitest"; +import { Environment, isGQLRequest } from "@hoppscotch/data"; +import { mkdtempSync, readFileSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +import { parseCollectionData } from "../../utils/mutators"; +import { getTestJsonFilePath } from "../utils"; + +describe("collection ingestion — mixed and GraphQL fixtures", () => { + test("mixed-rest-gql-coll.json parses with REST and GraphQL requests in order", async () => { + const collections = await parseCollectionData( + getTestJsonFilePath("mixed-rest-gql-coll.json", "collection"), + {} + ); + + expect(collections).toHaveLength(1); + const [collection] = collections; + + expect(collection.requests.map((r) => isGQLRequest(r))).toEqual([ + false, + true, + ]); + + expect(collection.folders).toHaveLength(1); + expect(collection.folders[0].requests.map((r) => isGQLRequest(r))).toEqual([ + true, + ]); + + const gqlRequest = collection.requests[1]; + if (isGQLRequest(gqlRequest)) { + expect(gqlRequest.query).toContain("query GreetingEcho"); + expect(gqlRequest.headers[0].value).toContain("<>"); + } + }); + + test("gql-coll.json parses as an all-GraphQL collection", async () => { + const collections = await parseCollectionData( + getTestJsonFilePath("gql-coll.json", "collection"), + {} + ); + + expect(collections).toHaveLength(1); + const requests = collections[0].requests; + expect(requests).toHaveLength(3); + expect(requests.every((r) => isGQLRequest(r))).toBe(true); + + const scripted = requests[1]; + if (isGQLRequest(scripted)) { + expect(scripted.query).toContain("query AuthEcho"); + expect(scripted.preRequestScript).toContain("pw.env.set"); + } + }); + + + test("old-version GQL requests migrate to the latest schema on ingestion", async () => { + // v9 predates responses and the script fields + const legacyCollection = [ + { + v: 1, + name: "Legacy", + folders: [], + requests: [ + { + v: 9, + name: "Legacy GQL", + url: "https://echo.hoppscotch.io/graphql", + headers: [], + query: "query Hello { method }", + variables: "{}", + auth: { authType: "none", authActive: true }, + }, + ], + }, + ]; + const file = join( + mkdtempSync(join(tmpdir(), "hopp-gql-migration-")), + "legacy-coll.json" + ); + writeFileSync(file, JSON.stringify(legacyCollection)); + + const collections = await parseCollectionData(file, {}); + const request = collections[0].requests[0]; + expect(isGQLRequest(request)).toBe(true); + if (isGQLRequest(request)) { + expect(request.v).toBe(10); + expect(request.preRequestScript).toBe(""); + expect(request.testScript).toBe(""); + expect(request.responses).toEqual({}); + } + }); + + test("gql-envs.json is a latest-version environment export", () => { + const contents = JSON.parse( + readFileSync(getTestJsonFilePath("gql-envs.json", "environment"), "utf8") + ); + + const parsed = Environment.safeParse(contents); + expect(parsed.type).toBe("ok"); + if (parsed.type === "ok") { + expect(parsed.value.v).toBe(2); + expect(parsed.value.variables).toEqual([ + { + key: "greeting", + initialValue: "hello-world", + currentValue: "hello-world", + secret: false, + }, + ]); + } + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/gql-auth-body.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/gql-auth-body.spec.ts new file mode 100644 index 00000000000..e5614ac8c01 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/gql-auth-body.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, test, vi } from "vitest"; +import { Environment, HoppGQLRequest } from "@hoppscotch/data"; +import * as E from "fp-ts/Either"; + +import { preProcessGQLRequest } from "../../utils/gql-request"; + +// Capture what the signer is constructed with — the signature has to be +// computed over the body actually sent, not the stub placeholder +const signerCalls = vi.hoisted(() => [] as Record[]); + +vi.mock("aws4fetch", () => ({ + AwsV4Signer: class { + constructor(opts: Record) { + signerCalls.push(opts); + } + async sign() { + return { headers: new Map(), url: new URL("https://example.com") }; + } + }, +})); + +const { getEffectiveRESTRequest } = await import("../../utils/pre-request"); + +const collection = { + v: 12, + name: "Base", + folders: [], + requests: [], + headers: [], + auth: { authType: "none", authActive: false }, + variables: [], + preRequestScript: "", + testScript: "", +} as any; + +const gqlRequest = { + v: 10, + name: "Signed GQL", + url: "https://example.com/graphql", + headers: [], + query: "query Hello { hello }", + variables: '{ "id": "1" }', + auth: { + authType: "aws-signature", + authActive: true, + accessKey: "AKIAEXAMPLE", + secretKey: "secret", + region: "us-east-1", + serviceName: "execute-api", + serviceToken: "", + addTo: "HEADERS", + }, + description: null, + responses: {}, + preRequestScript: "", + testScript: "", +} as unknown as HoppGQLRequest; + +const emptyEnv: Environment = { + v: 2, + id: "env", + name: "env", + variables: [], +}; + +describe("GraphQL requests with signing auth", () => { + test("aws-signature signs the assembled GraphQL payload, not the empty stub body", async () => { + signerCalls.length = 0; + + const stub = preProcessGQLRequest(gqlRequest, collection); + // The stub carries a placeholder body until effective-request time + expect(stub.body.body).toBe(""); + + const result = await getEffectiveRESTRequest(stub, emptyEnv); + expect(E.isRight(result)).toBe(true); + + expect(signerCalls).toHaveLength(1); + const signedBody = signerCalls[0].body as string; + + expect(JSON.parse(signedBody)).toEqual({ + query: "query Hello { hello }", + variables: { id: "1" }, + operationName: "Hello", + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/gql-request.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/gql-request.spec.ts new file mode 100644 index 00000000000..f1ee14b0c6f --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/gql-request.spec.ts @@ -0,0 +1,325 @@ +import { describe, expect, test } from "vitest"; +import { + EnvironmentVariable, + HoppCollection, + HoppGQLRequest, +} from "@hoppscotch/data"; +import * as E from "fp-ts/Either"; + +import { + GQLStubRequest, + buildEffectiveGQLPayload, + isGQLStubRequest, + preProcessGQLRequest, +} from "../../utils/gql-request"; + +const baseCollection = { + v: 12, + name: "Base", + folders: [], + requests: [], + headers: [], + auth: { authType: "none", authActive: false }, + variables: [], + preRequestScript: "", + testScript: "", +} as unknown as HoppCollection; + +const baseRequest: HoppGQLRequest = { + v: 10, + name: "Get Hello", + url: "https://echo.hoppscotch.io/graphql", + headers: [], + query: "query HelloOp { hello }", + variables: '{ "id": "1" }', + auth: { authType: "none", authActive: true }, + description: null, + responses: {}, + preRequestScript: 'pw.env.set("a", "b")', + testScript: 'pw.test("t", () => {})', +}; + +const envs = (vars: Record): EnvironmentVariable[] => + Object.entries(vars).map(([key, value]) => ({ + key, + initialValue: value, + currentValue: value, + secret: false, + })); + +const stubFor = ( + request: Partial, + collection: HoppCollection = baseCollection +): GQLStubRequest => + preProcessGQLRequest({ ...baseRequest, ...request } as HoppGQLRequest, collection); + +const payloadFor = ( + request: Partial, + variables: EnvironmentVariable[] = [], + collection: HoppCollection = baseCollection +) => { + const result = buildEffectiveGQLPayload( + stubFor(request, collection), + variables + ); + expect(E.isRight(result)).toBe(true); + return JSON.parse((result as E.Right).right); +}; + +describe("preProcessGQLRequest", () => { + test("builds a REST-shaped POST stub carrying scripts and the raw query/variables", () => { + const stub = stubFor({}); + + expect(stub.method).toBe("POST"); + expect(stub.endpoint).toBe("https://echo.hoppscotch.io/graphql"); + expect(stub.body.contentType).toBe("application/json"); + expect(stub.preRequestScript).toBe('pw.env.set("a", "b")'); + expect(stub.testScript).toBe('pw.test("t", () => {})'); + expect(stub.gqlRaw).toEqual({ + query: "query HelloOp { hello }", + variables: '{ "id": "1" }', + }); + expect(isGQLStubRequest(stub)).toBe(true); + }); + + test("falls back to 'Untitled Request' for unnamed requests", () => { + expect(stubFor({ name: "" }).name).toBe("Untitled Request"); + }); + + test("an inactive request header does not suppress a same-key active parent header", () => { + const collection = { + ...baseCollection, + headers: [ + { key: "x-parent", value: "parent-value", active: true, description: "" }, + ], + } as HoppCollection; + + const stub = stubFor( + { + headers: [ + { key: "x-parent", value: "child-value", active: false, description: "" }, + ], + }, + collection + ); + + expect(stub.headers).toEqual([ + { key: "x-parent", value: "parent-value", active: true, description: "" }, + ]); + }); + + test("an active request header wins over a same-key parent header; inactive parents are dropped", () => { + const collection = { + ...baseCollection, + headers: [ + { key: "x-shared", value: "parent-value", active: true, description: "" }, + { key: "x-off", value: "off", active: false, description: "" }, + ], + } as HoppCollection; + + const stub = stubFor( + { + headers: [ + { key: "x-shared", value: "child-value", active: true, description: "" }, + ], + }, + collection + ); + + expect(stub.headers).toEqual([ + { key: "x-shared", value: "child-value", active: true, description: "" }, + ]); + }); + + test("auth inherit + active resolves to the parent auth", () => { + const collection = { + ...baseCollection, + auth: { authType: "bearer", token: "parent-token", authActive: true }, + } as HoppCollection; + + const stub = stubFor( + { auth: { authType: "inherit", authActive: true } }, + collection + ); + + expect(stub.auth).toEqual({ + authType: "bearer", + token: "parent-token", + authActive: true, + }); + }); + + test("auth inherit + inactive resolves to no auth even when the parent has auth", () => { + const collection = { + ...baseCollection, + auth: { authType: "bearer", token: "parent-token", authActive: true }, + } as HoppCollection; + + const stub = stubFor( + { auth: { authType: "inherit", authActive: false } }, + collection + ); + + expect(stub.auth).toEqual({ authType: "none", authActive: false }); + }); + + test("explicit request-level auth is carried onto the stub", () => { + const stub = stubFor({ + auth: { authType: "bearer", token: "req-token", authActive: true }, + }); + + expect(stub.auth).toEqual({ + authType: "bearer", + token: "req-token", + authActive: true, + }); + }); +}); + +describe("buildEffectiveGQLPayload", () => { + test("assembles query, parsed variables, and operationName for a named operation", () => { + const payload = payloadFor({}); + + expect(payload).toEqual({ + query: "query HelloOp { hello }", + variables: { id: "1" }, + operationName: "HelloOp", + }); + }); + + test("omits operationName for anonymous operations", () => { + const payload = payloadFor({ query: "{ hello }" }); + + expect(payload.operationName).toBeUndefined(); + }); + + test("selects the first operation of a multi-operation document", () => { + const payload = payloadFor({ + query: "query First { a }\nquery Second { b }", + }); + + expect(payload.operationName).toBe("First"); + }); + + test("an anonymous FIRST operation in a multi-op document sends no operationName (server reports the spec error)", () => { + const payload = payloadFor({ + query: "{ a }\nquery Second { b }", + }); + + expect(payload.operationName).toBeUndefined(); + }); + + test("resolves env templates in bare non-string variable positions", () => { + const payload = payloadFor( + { variables: '{ "count": <> }' }, + envs({ n: "5" }) + ); + + expect(payload.variables).toEqual({ count: 5 }); + }); + + test("a multi-line env value as the whole query document survives intact", () => { + const doc = "query FromEnv {\n method\n url\n}"; + const payload = payloadFor( + { query: "<>", variables: "" }, + envs({ doc }) + ); + + expect(payload.query).toBe(doc); + expect(payload.operationName).toBe("FromEnv"); + }); + + test("safe env values substitute into variables string positions", () => { + const payload = payloadFor( + { variables: '{ "m": "<> from CLI" }' }, + envs({ v: "hello-world" }) + ); + + expect(payload.variables.m).toBe("hello-world from CLI"); + }); + + test("env values that break the variables JSON fail loudly instead of sending a mangled body", () => { + // Substitution into `variables` is textual, so a raw quote in the value + // breaks the JSON — the app fails this identically + const result = buildEffectiveGQLPayload( + stubFor({ variables: '{ "m": "<>" }' }), + envs({ v: 'has a "quote"' }) + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(String(result.left.data)).toContain("Invalid JSON"); + } + }); + + test("a literal subscription fails with REQUEST_ERROR before any network call", () => { + const result = buildEffectiveGQLPayload( + stubFor({ query: "subscription S { countdown }" }), + [] + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left.code).toBe("REQUEST_ERROR"); + expect(String(result.left.data)).toContain( + "GraphQL subscriptions are not supported" + ); + } + }); + + test("a templated document resolving to a subscription is detected after substitution", () => { + const result = buildEffectiveGQLPayload( + stubFor({ query: "<>" }), + envs({ subdoc: "subscription S { countdown }" }) + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left.code).toBe("REQUEST_ERROR"); + } + }); + + test("invalid variables JSON fails with REQUEST_ERROR instead of sending a mangled body", () => { + const result = buildEffectiveGQLPayload( + stubFor({ variables: '{ "m": broken' }), + [] + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left.code).toBe("REQUEST_ERROR"); + expect(String(result.left.data)).toContain("Invalid JSON"); + } + }); + + test("empty variables text omits the variables key entirely", () => { + const payload = payloadFor({ variables: "" }); + + expect("variables" in payload).toBe(false); + }); + + test("whitespace-only variables fail as invalid JSON (app kernel parity)", () => { + const result = buildEffectiveGQLPayload(stubFor({ variables: " " }), []); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(String(result.left.data)).toContain("Invalid JSON"); + } + }); + + test("empty and fragment-only documents are sent as-is without operationName", () => { + expect(payloadFor({ query: "", variables: "" })).toEqual({ query: "" }); + + const fragmentOnly = "fragment F on Query { method }"; + const payload = payloadFor({ query: fragmentOnly }); + expect(payload.query).toBe(fragmentOnly); + expect(payload.operationName).toBeUndefined(); + }); + + test("an unparseable document is sent as-is for the server to report", () => { + const payload = payloadFor({ query: "query { unbalanced" }); + + expect(payload.query).toBe("query { unbalanced"); + expect(payload.operationName).toBeUndefined(); + }); +}); diff --git a/packages/hoppscotch-cli/src/utils/collections.ts b/packages/hoppscotch-cli/src/utils/collections.ts index 276d1542c65..66fc4fb5c05 100644 --- a/packages/hoppscotch-cli/src/utils/collections.ts +++ b/packages/hoppscotch-cli/src/utils/collections.ts @@ -1,4 +1,8 @@ -import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; +import { + HoppCollection, + HoppRESTRequest, + isGQLRequest, +} from "@hoppscotch/data"; import chalk from "chalk"; import { log } from "console"; import * as A from "fp-ts/Array"; @@ -27,6 +31,7 @@ import { } from "./display"; import { exceptionColors } from "./getters"; import { getPreRequestMetrics } from "./pre-request"; +import { preProcessGQLRequest } from "./gql-request"; import { buildJUnitReport, generateJUnitReportExport } from "./reporters/junit"; import { getRequestMetrics, @@ -125,9 +130,12 @@ const processCollection = async ( collection.testScript, ]); - // Process each request in the collection + // GraphQL requests become REST-shaped stubs so the shared pipeline runs + // them unchanged; unrunnable stubs fail at effective-request time for (const request of collection.requests) { - const _request = preProcessRequest(request as HoppRESTRequest, collection); + const _request = isGQLRequest(request) + ? preProcessGQLRequest(request, collection) + : preProcessRequest(request as HoppRESTRequest, collection); const requestPath = `${path}/${_request.name}`; const collectionVariables = collection.variables.filter( diff --git a/packages/hoppscotch-cli/src/utils/gql-request.ts b/packages/hoppscotch-cli/src/utils/gql-request.ts new file mode 100644 index 00000000000..448e97cb621 --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/gql-request.ts @@ -0,0 +1,150 @@ +import { + EnvironmentVariable, + HoppCollection, + HoppGQLRequest, + HoppRESTAuth, + HoppRESTRequest, + getDefaultRESTRequest, + parseTemplateString, +} from "@hoppscotch/data"; +import * as E from "fp-ts/Either"; +import { parse } from "graphql"; +import type { OperationDefinitionNode } from "graphql"; + +import { HoppCLIError, error } from "../types/errors"; + +/** + * A GraphQL request converted to a REST-shaped POST so the CLI's existing + * pipeline (templating, auth, scripts, metrics, reporting) runs it unchanged. + * + * `gqlRaw` carries the untemplated query/variables; the wire payload is + * assembled by {@link buildEffectiveGQLPayload} AFTER env templating — + * assembling earlier would JSON-escape the text before substitution and + * corrupt the payload on env values containing quotes or newlines. + */ +export interface GQLStubRequest extends HoppRESTRequest { + gqlRaw: { query: string; variables: string }; +} + +export const isGQLStubRequest = ( + request: HoppRESTRequest +): request is GQLStubRequest => "gqlRaw" in request; + +/** + * Converts a GraphQL request into a {@link GQLStubRequest}. Header/auth + * merging matches the app runner: inactive headers are dropped BEFORE the + * merge (so they can't suppress an active parent header), and `inherit` + * auth falls back to the parent only when active. + */ +export const preProcessGQLRequest = ( + request: HoppGQLRequest, + collection: HoppCollection +): GQLStubRequest => { + const requestHeaders = (request.headers ?? []).filter( + (header) => header.active && header.key !== "" + ); + const parentHeaders = (collection.headers ?? []).filter( + (header) => header.active && header.key !== "" + ); + const headers = [ + ...parentHeaders.filter( + (parentHeader) => + !requestHeaders.some((header) => header.key === parentHeader.key) + ), + ...requestHeaders, + ]; + + // GQL auth is a structural subset of REST auth; inherited collection auth + // can be a REST-only type (digest, HAWK, JWT…) + const requestAuth = (request.auth ?? { + authType: "none", + authActive: false, + }) as HoppRESTAuth; + const auth: HoppRESTAuth = + requestAuth.authType === "inherit" + ? requestAuth.authActive && collection.auth + ? (collection.auth as HoppRESTAuth) + : { authType: "none", authActive: false } + : requestAuth; + + return { + ...getDefaultRESTRequest(), + name: request.name || "Untitled Request", + method: "POST", + endpoint: request.url ?? "", + params: [], + headers, + auth, + preRequestScript: request.preRequestScript ?? "", + testScript: request.testScript ?? "", + // Placeholder — replaced from `gqlRaw` at effective-request time + body: { contentType: "application/json", body: "" }, + requestVariables: [], + gqlRaw: { + query: request.query ?? "", + variables: request.variables ?? "", + }, + }; +}; + +/** + * Assembles the GraphQL-over-HTTP JSON payload with env templating applied, + * mirroring the app's collection runner: the FIRST operation of the + * ENV-RESOLVED document runs (templated documents only reveal their + * operations after substitution), subscriptions and invalid variables JSON + * fail the row before any network call, and unparseable documents are sent + * as-is for the server to report. + */ +export const buildEffectiveGQLPayload = ( + request: GQLStubRequest, + envVariables: EnvironmentVariable[] +): E.Either => { + const query = parseTemplateString(request.gqlRaw.query, envVariables); + // No trim — the app kernel JSON.parses non-empty text as-is, so + // whitespace-only variables fail identically in both runners + const variablesText = parseTemplateString( + request.gqlRaw.variables, + envVariables + ); + + let operation: OperationDefinitionNode | null = null; + try { + const operations = parse(query).definitions.filter( + (definition): definition is OperationDefinitionNode => + definition.kind === "OperationDefinition" + ); + operation = operations[0] ?? null; + } catch (_e) { + // Unparseable document — the server's GraphQL error beats a client-side + // parse message + } + + if (operation?.operation === "subscription") { + return E.left( + error({ + code: "REQUEST_ERROR", + data: `GraphQL subscriptions are not supported in the CLI runner: ${request.name}`, + }) + ); + } + + let variables: unknown = undefined; + if (variablesText) { + try { + variables = JSON.parse(variablesText); + } catch (_e) { + return E.left( + error({ + code: "REQUEST_ERROR", + data: `Invalid JSON in GraphQL variables: ${request.name}`, + }) + ); + } + } + + const payload: Record = { query }; + if (variables !== undefined) payload.variables = variables; + if (operation?.name?.value) payload.operationName = operation.name.value; + + return E.right(JSON.stringify(payload)); +}; diff --git a/packages/hoppscotch-cli/src/utils/mutators.ts b/packages/hoppscotch-cli/src/utils/mutators.ts index 153e66b24a9..f997ffe9b7c 100644 --- a/packages/hoppscotch-cli/src/utils/mutators.ts +++ b/packages/hoppscotch-cli/src/utils/mutators.ts @@ -1,4 +1,9 @@ -import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; +import { + Environment, + HoppCollection, + HoppGQLRequest, + HoppRESTRequest, +} from "@hoppscotch/data"; import fs from "fs/promises"; import { entityReference } from "verzod"; import { z } from "zod"; @@ -14,9 +19,17 @@ const getValidRequests = ( collectionFilePath: string ) => { return collections.map((collection) => { - // Validate requests using zod schema + // Unified collections can mix REST and GraphQL requests — validate each + // entry against its own schema (REST first; a GraphQL request lacks + // `endpoint`, so it can never mis-validate as REST). Order is preserved: + // runs execute requests in collection order regardless of protocol. const requestSchemaParsedResult = z - .array(entityReference(HoppRESTRequest)) + .array( + z.union([ + entityReference(HoppRESTRequest), + entityReference(HoppGQLRequest), + ]) + ) .safeParse(collection.requests); // Handle validation errors diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index 83979862ebb..5ed419ee611 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -34,6 +34,7 @@ import { import { isHoppCLIError } from "./checks"; import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; +import { buildEffectiveGQLPayload, isGQLStubRequest } from "./gql-request"; import { stripComments } from "./jsonc"; import { toFormData } from "./mutators"; import { combineScriptsWithIIFE, filterValidScripts } from "@hoppscotch/js-sandbox/scripting"; @@ -179,14 +180,15 @@ export async function getEffectiveRESTRequest( } const effectiveFinalParams = _effectiveFinalParams.right; - // Parsing final-body with applied ENVs. - const _effectiveFinalBody = getFinalBodyFromRequest( - request, - resolvedVariables - ); + // Parsing final-body with applied ENVs. GraphQL stubs assemble their + // payload from the raw query/variables AFTER templating — see gql-request.ts + const _effectiveFinalBody = isGQLStubRequest(request) + ? buildEffectiveGQLPayload(request, resolvedVariables) + : getFinalBodyFromRequest(request, resolvedVariables); if (E.isLeft(_effectiveFinalBody)) { return _effectiveFinalBody; } + const effectiveFinalBody = _effectiveFinalBody.right; // Authentication if (request.auth.authActive) { @@ -259,11 +261,10 @@ export async function getEffectiveRESTRequest( const amzDate = currentDate.toISOString().replace(/[:-]|\.\d{3}/g, ""); const { method, endpoint } = request; - const body = getFinalBodyFromRequest(request, resolvedVariables); - const signer = new AwsV4Signer({ method, - body: E.isRight(body) ? body.right?.toString() : undefined, + // Must be the body actually sent, else the signature mismatches + body: effectiveFinalBody?.toString(), datetime: amzDate, signQuery: addTo === "QUERY_PARAMS", accessKeyId: parseTemplateString( @@ -337,7 +338,8 @@ export async function getEffectiveRESTRequest( opaque: request.auth.opaque ? parseTemplateString(request.auth.opaque, resolvedVariables) : authInfo.opaque, - reqBody: typeof request.body.body === "string" ? request.body.body : "", + reqBody: + typeof effectiveFinalBody === "string" ? effectiveFinalBody : "", }; // Step 3: Generate the Authorization header @@ -434,8 +436,6 @@ export async function getEffectiveRESTRequest( } } - const effectiveFinalBody = _effectiveFinalBody.right; - if ( request.body.contentType && !effectiveFinalHeaders.some( diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts index 3c42dd3850f..0548ef21f1e 100644 --- a/packages/hoppscotch-cli/src/utils/request.ts +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -290,6 +290,13 @@ export const processRequest = // Ensure, the CLI fails with a non-zero exit code if there are any errors report.result = false; + + // REQUEST_ERROR here means the request could not be constructed + // (GraphQL subscription / invalid variables) — nothing to send + if (preRequestRes.left.code === "REQUEST_ERROR") { + result.report = report; + return result; + } } else { // Updating effective-request and consuming updated envs after pre-request script execution ({ effectiveRequest, updatedEnvs } = preRequestRes.right); diff --git a/packages/hoppscotch-cli/src/utils/workspace-access.ts b/packages/hoppscotch-cli/src/utils/workspace-access.ts index 2c9a947fd09..2ad5c305dd9 100644 --- a/packages/hoppscotch-cli/src/utils/workspace-access.ts +++ b/packages/hoppscotch-cli/src/utils/workspace-access.ts @@ -6,6 +6,7 @@ import { HoppCollectionVariable, HoppRESTAuth, HoppRESTHeaders, + HoppGQLRequest, HoppRESTRequest, } from "@hoppscotch/data"; @@ -39,12 +40,17 @@ interface WorkspaceRequest { * Transforms the incoming list of workspace requests by applying `JSON.parse` to the `request` field. * It includes the `v` field indicating the schema version, but migration is handled already at the `parseCollectionData()` helper function. * + * Unified team collections can mix REST and GraphQL requests — both are + * returned (in order) and validated per-schema downstream in + * `getValidRequests`; the runner routes each by shape at execution time. + * * @param {WorkspaceRequest[]} requests - An array of workspace request objects to be transformed. - * @returns {HoppRESTRequest[]} The transformed array of requests conforming to the `HoppRESTRequest` type. + * @returns The transformed array of REST/GraphQL requests. */ const transformWorkspaceRequests = ( requests: WorkspaceRequest[] -): HoppRESTRequest[] => requests.map(({ request }) => JSON.parse(request)); +): (HoppRESTRequest | HoppGQLRequest)[] => + requests.map(({ request }) => JSON.parse(request)); /** * Apply relevant migrations for data conforming to older formats diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index ad5439d0f81..c6673e9fe4e 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -395,6 +395,8 @@ "empty_domains": "Domain list is empty", "invalid_domain": "Domain has invalid characters", "enter_cookie_string": "Enter cookie string", + "host_only": "HostOnly", + "host_only_info": "Sent to the host that set it. Subdomains do not receive it.", "http_only": "HttpOnly", "http_only_info": "Not exposed to page scripts. Still sent on matching requests.", "interceptor_no_support": "Your currently selected interceptor does not support cookies. Select a different Interceptor and try again.", @@ -473,6 +475,7 @@ "title": "Body" }, "copied_to_clipboard": "Copied to clipboard!", + "copy_to_clipboard": "Copy to clipboard", "curl": { "click_to_load": "Click to load cURL command", "copied": "cURL command copied to clipboard!", @@ -501,6 +504,7 @@ "no_documentation_found": "No documentation found for folders or requests", "no_request_data": "No request data available", "no_requests_or_folders": "No requests or folders", + "no_write_access": "You don't have write access to this documentation", "not_set": "Not set", "open_request_in_new_tab": "Open request in new tab", "parameters": { @@ -516,6 +520,7 @@ "button": "Publish", "copy_url": "Copy URL", "delete": "Delete Documentation", + "delete_error": "Failed to delete the published documentation", "unpublish_doc": "Are you sure you want to unpublish the documentation?", "delete_success": "Published documentation deleted successfully", "doc_title": "Title", @@ -585,6 +590,9 @@ "untitled_collection": "Untitled Collection", "untitled_request": "Untitled Request", "value": "Value", + "query": { + "title": "Query" + }, "variables": { "no_vars": "No variables defined", "title": "Variables" @@ -823,19 +831,25 @@ "arguments": "Arguments", "connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?", "connection_error_http": "Failed to fetch GraphQL Schema due to network error.", - "connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is", + "connection_error_introspection_disabled": "Introspection is disabled on this server.", + "connection_error_invalid_json": "Introspection response was not valid JSON — the endpoint may not be a GraphQL server.", + "connection_switch_new_url": "Switching to this tab will disconnect you from the active GraphQL connection. The new connection URL is", "connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is", "deprecated": "Deprecated", "fields": "Fields", "mutation": "Mutation", "mutations": "Mutations", + "operation_error": "Something went wrong while running the operation", "schema": "Schema", "show_depricated_values": "Show deprecated values", "subscription": "Subscription", + "subscription_log": "Subscription Log", "subscriptions": "Subscriptions", + "subscribing": "Connecting to subscription endpoint…", "switch_connection": "Switch connection", "url_placeholder": "Enter a GraphQL endpoint URL", - "query": "Query" + "query": "Query", + "waiting_for_events": "Subscribed. Waiting for events…" }, "graphql_collections": { "title": "GraphQL Collections" @@ -1198,6 +1212,7 @@ "show_content_type": "Show Content Type", "different_collection": "Cannot reorder requests from different collections", "duplicated": "Request duplicated", + "rest_in_gql_collection": "This is a REST request — open it from the REST workspace", "duration": "Duration", "enter_curl": "Enter cURL command", "generate_code": "Generate code", @@ -1210,6 +1225,7 @@ "moved": "Request moved", "name": "Request name", "new": "New Request", + "new_gql": "New GraphQL Request", "order_changed": "Request Order Updated", "override": "Override", "override_help": "Set Content-Type in Headers", @@ -1232,7 +1248,9 @@ "share_description": "Share Hoppscotch with your friends", "share_request": "Share Request", "stop": "Stop", + "switch_protocol": "Switch protocol", "title": "Request", + "try": "Try", "type": "Request type", "url": "URL", "url_placeholder": "Enter a URL or paste a cURL command", @@ -1267,6 +1285,7 @@ "generate_data_schema": "Generate Data Schema", "data_schema": "Data Schema", "saved": "Response saved", + "save_as_example": "Save as example", "invalid_name": "Please provide a name for the response" }, "script": { @@ -1374,6 +1393,7 @@ "experimental_scripting_sandbox": "Experimental scripting sandbox", "enable_experimental_mock_servers": "Enable Mock Servers", "enable_experimental_documentation": "Enable Documentation", + "enable_gql_in_rest_workspace": "Enable Unified GraphQL workspace", "sync": "Synchronise", "sync_collections": "Collections", "sync_description": "These settings are synced to cloud.", @@ -1427,6 +1447,7 @@ "proxy_auth": "You can also include username and password in the URL." }, "shared_requests": { + "auth_warning": "This request contains authentication credentials. Anyone with the share link will be able to read them — including tokens, passwords, and API keys.", "button": "Button", "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.", "copy_html": "Copy HTML", @@ -1939,6 +1960,7 @@ "snippets": "Snippets", "run": "Run", "run_again": "Run again", + "running": "Running tests...", "stop": "Stop", "new_run": "New Run", "iterations": "Iterations", @@ -1996,6 +2018,39 @@ "cli_comming_soon_for_personal_collection": "Collection Runner for personal collections in CLI is coming soon.", "delay": "Delay", "negative_delay": "Delay cannot be negative", + "iterations": "Iterations", + "time": "time", + "times": "times", + "invalid_iterations": "Iterations must be at least 1", + "data_feed": "Data Feed", + "data_feed_help": "Import a CSV or JSON file to run one iteration per row.", + "select_data_file": "Select CSV / JSON File", + "data_preview": "Data Preview", + "dataset_row": "{count} row", + "dataset_rows": "{count} rows", + "dataset_preview_truncated": "Showing the first {count} rows of the dataset.", + "dataset_iterations_info": "Dataset contains {rows} rows, running {total} iterations as per your configuration.", + "iterations_locked_to_dataset": "Iterations match the dataset row count. Remove the data file to set this manually.", + "jump_to_iteration": "Jump to iteration", + "previous_iteration": "Previous iteration", + "next_iteration": "Next iteration", + "invalid_data_file": "Invalid data file", + "empty_data_file": "Data file is empty", + "data_file_size_limit_exceeded": "The data file exceeds the {sizeLimit} MB limit", + "collection_load_failed": "Could not load this collection.", + "filter_requests": "Filter requests", + "no_requests_match_filter": "No requests match the filter", + "selection_reset": "The collection changed, so all requests will run.", + "selection_partially_reset": "Some selected requests no longer exist and were removed from the selection.", + "remove_file": "Remove file", + "run_sequence": "Run Sequence", + "reset_run_order": "Reset", + "export_results": "Export results", + "select_all": "Select all", + "deselect_all": "Deselect all", + "selected_requests_count": "{selected} of {total} selected", + "no_requests_selected": "Select at least one request to run", + "iteration": "Iteration {count}", "ui": "Runner", "running_collection": "Running collection", "run_config": "Run Configuration", @@ -2012,6 +2067,7 @@ "cli_command_generation_description_sh": "Copy the below command and run it from the CLI. Please specify a personal access token and verify the generated SH instance server URL.", "cli_command_generation_description_sh_with_server_url_placeholder": "Copy the below command and run it from the CLI. Please specify a personal access token and the SH instance server URL.", "run_collection": "Run collection", + "results_not_restored": "Run results aren't kept after a reload. Run the collection again to see them.", "no_passed_tests": "No tests passed", "no_failed_tests": "No tests failed" }, diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json index a725e7b0ed8..664c5b05b90 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -1,7 +1,7 @@ { "name": "@hoppscotch/common", "private": true, - "version": "2026.7.0", + "version": "2026.8.0", "scripts": { "dev": "pnpm exec npm-run-all -p -l dev:*", "test": "vitest --run", @@ -44,26 +44,26 @@ "@hoppscotch/ui": "0.2.6", "@hoppscotch/vue-toasted": "0.1.0", "@lezer/highlight": "1.2.1", - "@noble/curves": "2.2.0", - "@scure/base": "2.2.0", + "@noble/curves": "2.3.0", + "@scure/base": "2.3.0", "@shopify/lang-jsonc": "1.0.1", "@tauri-apps/api": "2.1.1", "@tauri-apps/plugin-store": "2.4.1", "@types/hawk": "9.0.7", - "@types/markdown-it": "14.1.2", + "@types/markdown-it": "14.2.0", "@types/node": "24.10.1", "@unhead/vue": "2.1.12", "@urql/core": "6.0.3", "@urql/devtools": "2.0.3", "@urql/exchange-auth": "3.0.0", - "@vueuse/core": "14.3.0", + "@vueuse/core": "14.4.0", "acorn-walk": "8.3.5", "aws4fetch": "1.0.20", - "axios": "1.18.1", + "axios": "1.19.0", "buffer": "6.0.3", "cookie-es": "2.0.0", "dioc": "3.0.2", - "dompurify": "3.4.12", + "dompurify": "3.4.14", "esprima": "4.0.1", "events": "3.3.0", "fp-ts": "2.16.11", @@ -72,23 +72,24 @@ "graphql-language-service-interface": "2.10.2", "graphql-tag": "2.12.7", "hawk": "9.0.2", - "highlight.js": "11.11.1", + "highlight.js": "11.12.0", "highlightjs-curl": "1.3.0", "insomnia-importers": "3.6.0", "io-ts": "2.2.22", "jq-wasm": "1.1.0-jq-1.8.1", "js-md5": "0.8.3", - "js-yaml": "4.2.0", + "js-yaml": "4.3.1", "jsonc-parser": "3.3.1", "lodash-es": "4.18.1", - "lossless-json": "4.3.0", + "lossless-json": "4.3.1", "markdown-it": "14.3.0", "minisearch": "7.2.0", "monaco-editor": "0.55.1", "nprogress": "0.2.0", "paho-mqtt": "1.1.0", + "papaparse": "5.5.4", "path": "0.12.7", - "postman-collection": "5.3.0", + "postman-collection": "5.3.1", "process": "0.11.10", "qs": "6.15.3", "quicktype-core": "23.2.6", @@ -111,8 +112,8 @@ "util": "0.12.5", "uuid": "13.0.0", "verzod": "0.4.0", - "vue": "3.5.38", - "vue-i18n": "11.4.6", + "vue": "3.5.41", + "vue-i18n": "11.4.8", "vue-json-pretty": "2.6.0", "vue-pdf-embed": "2.1.5", "vue-router": "4.6.4", @@ -137,9 +138,9 @@ "@graphql-codegen/typescript-urql-graphcache": "3.1.1", "@graphql-codegen/urql-introspection": "3.0.1", "@graphql-typed-document-node/core": "3.2.0", - "@iconify-json/lucide": "1.2.118", + "@iconify-json/lucide": "1.2.125", "@import-meta-env/cli": "0.7.4", - "@intlify/unplugin-vue-i18n": "11.2.4", + "@intlify/unplugin-vue-i18n": "11.2.5", "@relmify/jest-fp-ts": "2.1.1", "@rushstack/eslint-patch": "1.16.1", "@types/har-format": "1.2.16", @@ -147,6 +148,7 @@ "@types/lodash-es": "4.17.12", "@types/nprogress": "0.2.3", "@types/paho-mqtt": "1.0.10", + "@types/papaparse": "5.5.2", "@types/postman-collection": "3.5.11", "@types/qs": "6.15.1", "@types/splitpanes": "2.2.6", @@ -154,25 +156,25 @@ "@typescript-eslint/eslint-plugin": "8.64.0", "@typescript-eslint/parser": "8.64.0", "@vitejs/plugin-vue": "6.0.8", - "@vue/compiler-sfc": "3.5.40", + "@vue/compiler-sfc": "3.5.41", "@vue/eslint-config-typescript": "14.9.0", - "@vue/runtime-core": "3.5.40", + "@vue/runtime-core": "3.5.41", "autoprefixer": "10.5.4", "cross-env": "10.1.0", "dotenv": "17.4.2", "eslint": "9.39.2", "eslint-plugin-prettier": "5.5.6", - "eslint-plugin-vue": "10.9.2", + "eslint-plugin-vue": "10.10.0", "glob": "13.0.6", "globals": "16.5.0", "jsdom": "27.4.0", "npm-run-all": "4.1.5", "openapi-types": "12.1.3", - "postcss": "8.5.20", - "prettier": "3.8.5", + "postcss": "8.5.26", + "prettier": "3.9.6", "prettier-plugin-tailwindcss": "0.7.2", "rollup-plugin-polyfill-node": "0.13.0", - "sass": "1.101.0", + "sass": "1.103.1", "tailwindcss": "3.4.16", "tsup": "8.5.1", "typescript": "5.9.3", @@ -185,9 +187,9 @@ "vite-plugin-html-config": "2.0.2", "vite-plugin-pages": "0.33.3", "vite-plugin-pages-sitemap": "1.7.1", - "vite-plugin-pwa": "1.2.0", + "vite-plugin-pwa": "1.3.0", "vite-plugin-vue-layouts": "0.11.0", - "vitest": "4.1.10", + "vitest": "4.1.11", "vue-tsc": "1.8.8" } } diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 46b4f3976a9..1211528f261 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -68,8 +68,10 @@ declare module 'vue' { CollectionsDocumentationRequestPreview: typeof import('./components/collections/documentation/RequestPreview.vue')['default'] CollectionsDocumentationSectionsAuth: typeof import('./components/collections/documentation/sections/Auth.vue')['default'] CollectionsDocumentationSectionsCurlView: typeof import('./components/collections/documentation/sections/CurlView.vue')['default'] + CollectionsDocumentationSectionsGqlVariables: typeof import('./components/collections/documentation/sections/GqlVariables.vue')['default'] CollectionsDocumentationSectionsHeaders: typeof import('./components/collections/documentation/sections/Headers.vue')['default'] CollectionsDocumentationSectionsParameters: typeof import('./components/collections/documentation/sections/Parameters.vue')['default'] + CollectionsDocumentationSectionsQuery: typeof import('./components/collections/documentation/sections/Query.vue')['default'] CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default'] CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default'] CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.vue')['default'] @@ -108,6 +110,8 @@ declare module 'vue' { DocumentationHeader: typeof import('./components/documentation/Header.vue')['default'] DocumentationSkeleton: typeof import('./components/documentation/Skeleton.vue')['default'] Embeds: typeof import('./components/embeds/index.vue')['default'] + EmbedsGQLIndex: typeof import('./components/embeds/GQLIndex.vue')['default'] + EmbedsGQLRequest: typeof import('./components/embeds/GQLRequest.vue')['default'] EmbedsHeader: typeof import('./components/embeds/Header.vue')['default'] EmbedsRequest: typeof import('./components/embeds/Request.vue')['default'] Environments: typeof import('./components/environments/index.vue')['default'] @@ -123,6 +127,37 @@ declare module 'vue' { EnvironmentsTeamsEnvironment: typeof import('./components/environments/teams/Environment.vue')['default'] FirebaseLogin: typeof import('./components/firebase/Login.vue')['default'] FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default'] + GqlArgument: typeof import('./components/gql/Argument.vue')['default'] + GqlArguments: typeof import('./components/gql/Arguments.vue')['default'] + GqlAuthorization: typeof import('./components/gql/Authorization.vue')['default'] + GqlDefaultValue: typeof import('./components/gql/DefaultValue.vue')['default'] + GqlDirectives: typeof import('./components/gql/Directives.vue')['default'] + GqlDocExplorer: typeof import('./components/gql/DocExplorer.vue')['default'] + GqlEnumValues: typeof import('./components/gql/EnumValues.vue')['default'] + GqlExampleResponse: typeof import('./components/gql/example/Response.vue')['default'] + GqlExampleResponseRequest: typeof import('./components/gql/example/ResponseRequest.vue')['default'] + GqlExampleResponseTab: typeof import('./components/gql/example/ResponseTab.vue')['default'] + GqlExplorerSection: typeof import('./components/gql/ExplorerSection.vue')['default'] + GqlField: typeof import('./components/gql/Field.vue')['default'] + GqlFieldDocumentation: typeof import('./components/gql/FieldDocumentation.vue')['default'] + GqlFieldLink: typeof import('./components/gql/FieldLink.vue')['default'] + GqlFields: typeof import('./components/gql/Fields.vue')['default'] + GqlHeaders: typeof import('./components/gql/Headers.vue')['default'] + GqlImplementsInterfaces: typeof import('./components/gql/ImplementsInterfaces.vue')['default'] + GqlQuery: typeof import('./components/gql/Query.vue')['default'] + GqlRequest: typeof import('./components/gql/Request.vue')['default'] + GqlRequestOptions: typeof import('./components/gql/RequestOptions.vue')['default'] + GqlRequestTab: typeof import('./components/gql/RequestTab.vue')['default'] + GqlResponse: typeof import('./components/gql/Response.vue')['default'] + GqlResponseMeta: typeof import('./components/gql/ResponseMeta.vue')['default'] + GqlSchema: typeof import('./components/gql/Schema.vue')['default'] + GqlSchemaDocumentation: typeof import('./components/gql/SchemaDocumentation.vue')['default'] + GqlSchemaSearch: typeof import('./components/gql/SchemaSearch.vue')['default'] + GqlSubscriptionLog: typeof import('./components/gql/SubscriptionLog.vue')['default'] + GqlTabHead: typeof import('./components/gql/TabHead.vue')['default'] + GqlTypeDocumentation: typeof import('./components/gql/TypeDocumentation.vue')['default'] + GqlTypeLink: typeof import('./components/gql/TypeLink.vue')['default'] + GqlVariable: typeof import('./components/gql/Variable.vue')['default'] GraphqlArgument: typeof import('./components/graphql/Argument.vue')['default'] GraphqlArguments: typeof import('./components/graphql/Arguments.vue')['default'] GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default'] @@ -154,6 +189,7 @@ declare module 'vue' { GraphqlVariable: typeof import('./components/graphql/Variable.vue')['default'] History: typeof import('./components/history/index.vue')['default'] HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default'] + HistoryGraphqlMergedCard: typeof import('./components/history/graphql/MergedCard.vue')['default'] HistoryPersonal: typeof import('./components/history/Personal.vue')['default'] HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default'] HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'] @@ -208,6 +244,7 @@ declare module 'vue' { HttpKeyValue: typeof import('./components/http/KeyValue.vue')['default'] HttpParameters: typeof import('./components/http/Parameters.vue')['default'] HttpPreRequestScript: typeof import('./components/http/PreRequestScript.vue')['default'] + HttpProtocolSwitcher: typeof import('./components/http/ProtocolSwitcher.vue')['default'] HttpRawBody: typeof import('./components/http/RawBody.vue')['default'] HttpReqChangeConfirmModal: typeof import('./components/http/ReqChangeConfirmModal.vue')['default'] HttpRequest: typeof import('./components/http/Request.vue')['default'] @@ -233,6 +270,7 @@ declare module 'vue' { HttpTestRunner: typeof import('./components/http/test/Runner.vue')['default'] HttpTestRunnerMeta: typeof import('./components/http/test/RunnerMeta.vue')['default'] HttpTestRunnerModal: typeof import('./components/http/test/RunnerModal.vue')['default'] + HttpTestRunnerRequestSelector: typeof import('./components/http/test/RunnerRequestSelector.vue')['default'] HttpTestRunnerResult: typeof import('./components/http/test/RunnerResult.vue')['default'] HttpTests: typeof import('./components/http/Tests.vue')['default'] HttpTestTestResult: typeof import('./components/http/test/TestResult.vue')['default'] @@ -242,6 +280,7 @@ declare module 'vue' { IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] + IconLucideBox: typeof import('~icons/lucide/box')['default'] IconLucideBrush: typeof import('~icons/lucide/brush')['default'] IconLucideCheck: typeof import('~icons/lucide/check')['default'] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] @@ -316,11 +355,13 @@ declare module 'vue' { SettingsProxy: typeof import('./components/settings/Proxy.vue')['default'] Share: typeof import('./components/share/index.vue')['default'] ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default'] + ShareCustomizeGQLModal: typeof import('./components/share/CustomizeGQLModal.vue')['default'] ShareCustomizeModal: typeof import('./components/share/CustomizeModal.vue')['default'] ShareModal: typeof import('./components/share/Modal.vue')['default'] ShareRequest: typeof import('./components/share/Request.vue')['default'] ShareTemplatesButton: typeof import('./components/share/templates/Button.vue')['default'] ShareTemplatesEmbeds: typeof import('./components/share/templates/Embeds.vue')['default'] + ShareTemplatesEmbedsGQL: typeof import('./components/share/templates/EmbedsGQL.vue')['default'] ShareTemplatesLink: typeof import('./components/share/templates/Link.vue')['default'] SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default'] SmartChangeLanguage: typeof import('./components/smart/ChangeLanguage.vue')['default'] diff --git a/packages/hoppscotch-common/src/components/app/Inspection.vue b/packages/hoppscotch-common/src/components/app/Inspection.vue index fa628bf5d73..c0a12ada648 100644 --- a/packages/hoppscotch-common/src/components/app/Inspection.vue +++ b/packages/hoppscotch-common/src/components/app/Inspection.vue @@ -52,7 +52,7 @@ (), + { + documentationUrl: + "https://docs.hoppscotch.io/documentation/features/rest-api-testing#response", + } +) + const t = useI18n() diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTRequest.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTRequest.vue index 95f2510e892..3d8c69cccbd 100644 --- a/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTRequest.vue +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/RESTRequest.vue @@ -13,6 +13,12 @@ > {{ request.method.toUpperCase() }} + + + {{ request.name }} @@ -22,8 +28,10 @@ diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Query.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Query.vue new file mode 100644 index 00000000000..2583584a301 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Query.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue index 6d7edcf0a5c..1678014a8f7 100644 --- a/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue @@ -19,8 +19,7 @@
{{ formatJSON(body.body) }}
+ >{{ formatJSON(body.body) }}
@@ -55,8 +54,7 @@
{{ body.body }}
+ >{{ body.body }}
diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue index b2367bbabeb..2812033fc11 100644 --- a/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue @@ -51,14 +51,12 @@
{{ formatJSON(example.body) }}
+ >{{ formatJSON(example.body) }}
{{ example.body }}
+ >{{ example.body }}
diff --git a/packages/hoppscotch-common/src/components/collections/graphql/index.vue b/packages/hoppscotch-common/src/components/collections/graphql/index.vue index cd454fc369b..cd4027500df 100644 --- a/packages/hoppscotch-common/src/components/collections/graphql/index.vue +++ b/packages/hoppscotch-common/src/components/collections/graphql/index.vue @@ -186,6 +186,7 @@ import { getDefaultGQLRequest, HoppCollection, HoppGQLRequest, + isGQLRequest, } from "@hoppscotch/data" import { Picked } from "~/helpers/types/HoppPicked" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" @@ -528,6 +529,12 @@ const selectRequest = ({ folderPath: string requestIndex: number }) => { + // GQL collections can hold REST-shaped requests (mixed import) — they + // can't open as GQL tabs on this page + if (!isGQLRequest(request)) { + toast.error(t("request.rest_in_gql_collection")) + return + } const possibleTab = tabs.getTabRefWithSaveContext({ originLocation: "user-collection", folderPath: folderPath, diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index d0b24107099..6ac9a15ffcd 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -42,6 +42,7 @@ " @add-folder="addFolder" @add-request="addRequest" + @add-gql-request="addGqlRequest" @edit-request="editRequest" @edit-collection="editCollection" @edit-folder="editFolder" @@ -94,6 +95,7 @@ :collection-move-loading="collectionMoveLoading" :request-move-loading="requestMoveLoading" @add-request="addRequest" + @add-gql-request="addGqlRequest" @add-folder="addFolder" @collection-click="handleCollectionClick" @duplicate-collection="duplicateCollection" @@ -150,6 +152,7 @@ @@ -304,13 +307,20 @@ import { generateUniqueRefId, getDefaultRESTRequest, HoppCollection, + HoppGQLRequest, + HoppGQLRequestResponse, HoppRESTAuth, HoppRESTHeaders, HoppRESTRequest, HoppRESTRequestResponse, + isGQLRequest, makeCollection, + makeHoppGQLResponseOriginalRequest, makeHoppRESTResponseOriginalRequest, } from "@hoppscotch/data" +import { getDefaultGQLRequest } from "~/helpers/graphql/default" +import { parse as parseGQLDocument } from "graphql" +import type { OperationDefinitionNode } from "graphql" import { useService } from "dioc/vue" import { stripJsonSerializedModulePrefix } from "@hoppscotch/js-sandbox/scripting" @@ -366,6 +376,7 @@ import { stripRefIdReplacer } from "~/helpers/import-export/export" import { hoppCollectionToOpenAPI } from "~/helpers/import-export/export/openapi" import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter" import { TeamSearchService } from "~/helpers/teams/TeamsSearch.service" +import { HoppTabDocument } from "~/helpers/tab/document" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { Picked } from "~/helpers/types/HoppPicked" import { @@ -396,7 +407,7 @@ import { currentReorderingStatus$ } from "~/newstore/reordering" import { platform } from "~/platform" import { PersistedOAuthConfig } from "~/services/oauth/oauth.service" import { PersistenceService } from "~/services/persistence" -import { RESTTabService } from "~/services/tab/rest" +import { WorkspaceTabsService } from "~/services/tab/workspace-tabs" import { TeamWorkspace, WorkspaceService } from "~/services/workspace.service" import { RESTOptionTabs } from "../http/RequestOptions.vue" import { Collection as NodeCollection } from "./MyCollections.vue" @@ -417,7 +428,7 @@ import { const t = useI18n() const toast = useToast() -const tabs = useService(RESTTabService) +const tabs = useService(WorkspaceTabsService) const props = defineProps({ saveRequest: { @@ -461,8 +472,9 @@ const editingCollectionPath = ref(null) const editingFolder = ref(null) const editingFolderName = ref(null) const editingFolderPath = ref(null) +const requestTypeToAdd = ref<"rest" | "gql">("rest") -const editingRequest = ref(null) +const editingRequest = ref(null) const editingRequestName = ref("") const editingResponseName = ref("") const editingResponseOldName = ref("") @@ -494,6 +506,18 @@ const currentUser = useReadonlyStream( const myCollections = useReadonlyStream(restCollections$, [], "deep") +const setRequestTabResponses = ( + tabRef: { value: { document: HoppTabDocument } }, + responses: HoppRESTRequest["responses"] | HoppGQLRequest["responses"] +) => { + const doc = tabRef.value.document + if (doc.type === "request") { + doc.request.responses = responses as HoppRESTRequest["responses"] + } else if (doc.type === "gql-request") { + doc.request.responses = responses as HoppGQLRequest["responses"] + } +} + // Dragging const draggingToRoot = ref(false) const collectionMoveLoading = ref([]) @@ -707,15 +731,16 @@ const filteredCollections = computed(() => { const isMatch = (text: string) => text.toLowerCase().includes(filterText) - const isRequestMatch = (request: HoppRESTRequest) => - isMatch(request.name) || isMatch(request.endpoint) + const isRequestMatch = (request: HoppRESTRequest | HoppGQLRequest) => + isMatch(request.name) || + (!isGQLRequest(request) && isMatch(request.endpoint)) || + (isGQLRequest(request) && isMatch(request.url)) for (const collection of collections) { const filteredRequests = [] const filteredFolders = [] for (const request of collection.requests) { - if (isRequestMatch(request as HoppRESTRequest)) - filteredRequests.push(request) + if (isRequestMatch(request)) filteredRequests.push(request) } for (const folder of collection.folders) { if (isMatch(folder.name)) filteredFolders.push(folder) @@ -1006,14 +1031,27 @@ const addRequest = (payload: { const { path, folder } = payload editingFolder.value = folder editingFolderPath.value = path + requestTypeToAdd.value = "rest" + displayModalAddRequest(true) +} + +const addGqlRequest = (payload: { + path: string + folder: HoppCollection | TeamCollection +}) => { + const { path, folder } = payload + editingFolder.value = folder + editingFolderPath.value = path + requestTypeToAdd.value = "gql" displayModalAddRequest(true) } const onAddRequest = async (requestName: string) => { - const newRequest = { - ...getDefaultRESTRequest(), - name: requestName, - } + const isGqlRequest = requestTypeToAdd.value === "gql" + + const newRequest = isGqlRequest + ? { ...getDefaultGQLRequest(), name: requestName } + : { ...getDefaultRESTRequest(), name: requestName } const path = editingFolderPath.value if (!path) return @@ -1023,24 +1061,40 @@ const onAddRequest = async (requestName: string) => { const insertionIndex = saveRESTRequestAs(path, newRequest) - tabs.createNewTab({ - type: "request", - request: newRequest, - isDirty: false, - saveContext: { - originLocation: "user-collection", - folderPath: path, - requestIndex: insertionIndex, - requestRefID: newRequest._ref_id, - }, - inheritedProperties: cascadeParentCollectionForProperties(path, "rest"), - }) + if (isGqlRequest) { + tabs.createNewTab({ + type: "gql-request", + request: newRequest as HoppGQLRequest, + isDirty: false, + cursorPosition: 0, + saveContext: { + originLocation: "user-collection", + folderPath: path, + requestIndex: insertionIndex, + requestRefID: (newRequest as HoppGQLRequest)._ref_id, + }, + inheritedProperties: cascadeParentCollectionForProperties(path, "rest"), + }) + } else { + tabs.createNewTab({ + type: "request", + request: newRequest as HoppRESTRequest, + isDirty: false, + saveContext: { + originLocation: "user-collection", + folderPath: path, + requestIndex: insertionIndex, + requestRefID: (newRequest as HoppRESTRequest)._ref_id, + }, + inheritedProperties: cascadeParentCollectionForProperties(path, "rest"), + }) + } platform.analytics?.logEvent({ type: "HOPP_SAVE_REQUEST", workspaceType: "personal", createdNow: true, - platform: "rest", + platform: isGqlRequest ? "gql" : "rest", }) displayModalAddRequest(false) @@ -1061,7 +1115,7 @@ const onAddRequest = async (requestName: string) => { platform.analytics?.logEvent({ type: "HOPP_SAVE_REQUEST", workspaceType: "team", - platform: "rest", + platform: isGqlRequest ? "gql" : "rest", createdNow: true, }) @@ -1075,20 +1129,42 @@ const onAddRequest = async (requestName: string) => { (result) => { const { createRequestInCollection } = result - tabs.createNewTab({ - type: "request", - request: newRequest, - isDirty: false, - saveContext: { - originLocation: "team-collection", - requestID: createRequestInCollection.id, - collectionID: path, - teamID: createRequestInCollection.collection.team.id, - requestRefID: newRequest._ref_id, - }, - inheritedProperties: - teamCollectionService.cascadeParentCollectionForProperties(path), - }) + if (isGqlRequest) { + tabs.createNewTab({ + type: "gql-request", + request: newRequest as HoppGQLRequest, + isDirty: false, + cursorPosition: 0, + saveContext: { + originLocation: "team-collection", + requestID: createRequestInCollection.id, + collectionID: path, + teamID: createRequestInCollection.collection.team.id, + requestRefID: (newRequest as HoppGQLRequest)._ref_id, + }, + inheritedProperties: + teamCollectionService.cascadeParentCollectionForProperties( + path + ), + }) + } else { + tabs.createNewTab({ + type: "request", + request: newRequest as HoppRESTRequest, + isDirty: false, + saveContext: { + originLocation: "team-collection", + requestID: createRequestInCollection.id, + collectionID: path, + teamID: createRequestInCollection.collection.team.id, + requestRefID: (newRequest as HoppRESTRequest)._ref_id, + }, + inheritedProperties: + teamCollectionService.cascadeParentCollectionForProperties( + path + ), + }) + } modalLoadingState.value = false displayModalAddRequest(false) @@ -1355,7 +1431,7 @@ const duplicateCollection = async ({ const editRequest = (payload: { folderPath: string | undefined requestIndex: string - request: HoppRESTRequest + request: HoppRESTRequest | HoppGQLRequest }) => { const { folderPath, requestIndex, request } = payload editingRequest.value = request @@ -1396,7 +1472,8 @@ const updateEditingRequest = async (newName: string) => { if ( possibleActiveTab && - possibleActiveTab.value.document.type === "request" + (possibleActiveTab.value.document.type === "request" || + possibleActiveTab.value.document.type === "gql-request") ) { possibleActiveTab.value.document.request.name = requestUpdated.name nextTick(() => { @@ -1438,7 +1515,11 @@ const updateEditingRequest = async (newName: string) => { requestID, }) - if (possibleTab && possibleTab.value.document.type === "request") { + if ( + possibleTab && + (possibleTab.value.document.type === "request" || + possibleTab.value.document.type === "gql-request") + ) { possibleTab.value.document.request.name = requestName nextTick(() => { possibleTab.value.document.isDirty = false @@ -1450,7 +1531,7 @@ const updateEditingRequest = async (newName: string) => { type ResponseConfigPayload = { folderPath: string | undefined requestIndex: string - request: HoppRESTRequest + request: HoppRESTRequest | HoppGQLRequest responseName: string responseID: string } @@ -1521,12 +1602,17 @@ const updateEditingResponse = (newName: string) => { if ( possibleExampleActiveTab && - possibleExampleActiveTab.value.document.type === "example-response" + (possibleExampleActiveTab.value.document.type === "example-response" || + possibleExampleActiveTab.value.document.type === "gql-example-response") ) { possibleExampleActiveTab.value.document.response.name = newName nextTick(() => { - if (possibleExampleActiveTab.value.document.type === "test-runner") + const docType = possibleExampleActiveTab.value.document.type + if ( + docType !== "example-response" && + docType !== "gql-example-response" + ) return possibleExampleActiveTab.value.document.isDirty = false @@ -1539,13 +1625,8 @@ const updateEditingResponse = (newName: string) => { }) } - // update the request tab responses if it's open - if ( - possibleRequestActiveTab && - possibleRequestActiveTab.value.document.type === "request" - ) { - possibleRequestActiveTab.value.document.request.responses = - request.responses + if (possibleRequestActiveTab) { + setRequestTabResponses(possibleRequestActiveTab, request.responses) } displayModalEditResponse(false) @@ -1591,11 +1672,17 @@ const updateEditingResponse = (newName: string) => { if ( possibleActiveResponseTab && - possibleActiveResponseTab.value.document.type === "example-response" + (possibleActiveResponseTab.value.document.type === "example-response" || + possibleActiveResponseTab.value.document.type === + "gql-example-response") ) { possibleActiveResponseTab.value.document.response.name = newName nextTick(() => { - if (possibleActiveResponseTab.value.document.type === "test-runner") + const docType = possibleActiveResponseTab.value.document.type + if ( + docType !== "example-response" && + docType !== "gql-example-response" + ) return possibleActiveResponseTab.value.document.isDirty = false possibleActiveResponseTab.value.document.saveContext = { @@ -1606,30 +1693,26 @@ const updateEditingResponse = (newName: string) => { }) } - // update the request tab responses if it's open - if ( - possibleRequestActiveTab && - possibleRequestActiveTab.value.document.type === "request" - ) { - possibleRequestActiveTab.value.document.request.responses = - request.responses + if (possibleRequestActiveTab) { + setRequestTabResponses(possibleRequestActiveTab, request.responses) } } } const duplicateRequest = async (payload: { folderPath: string - request: HoppRESTRequest + request: HoppRESTRequest | HoppGQLRequest }) => { const { folderPath, request } = payload if (!folderPath) return const { id: _, ...requestWithoutID } = request + const cloned = cloneDeep(requestWithoutID) const newRequest = { - ...cloneDeep(requestWithoutID), + ...cloned, _ref_id: generateUniqueRefId("req"), name: `${request.name} - ${t("action.duplicate")}`, - } + } as HoppRESTRequest | HoppGQLRequest if (collectionsType.value.type === "my-collections") { const isValidToken = await handleTokenValidation() @@ -1704,13 +1787,8 @@ const duplicateResponse = async (payload: ResponseConfigPayload) => { folderPath, }) - // update the request tab responses if it's open - if ( - possibleRequestActiveTab && - possibleRequestActiveTab.value.document.type === "request" - ) { - possibleRequestActiveTab.value.document.request.responses = - updatedRequest.responses + if (possibleRequestActiveTab) { + setRequestTabResponses(possibleRequestActiveTab, updatedRequest.responses) } } else if (hasTeamWriteAccess.value) { duplicateRequestLoading.value = true @@ -1737,25 +1815,21 @@ const duplicateResponse = async (payload: ResponseConfigPayload) => { ) )() - // update the request tab responses if it's open + // update the request tab responses if it's open (REST or GQL) const possibleRequestActiveTab = tabs.getTabRefWithSaveContext({ originLocation: "team-collection", requestID: requestIndex, }) - if ( - possibleRequestActiveTab && - possibleRequestActiveTab.value.document.type === "request" - ) { - possibleRequestActiveTab.value.document.request.responses = - updatedRequest.responses + if (possibleRequestActiveTab) { + setRequestTabResponses(possibleRequestActiveTab, updatedRequest.responses) } } } const addExample = (payload: { folderPath: string - request: HoppRESTRequest + request: HoppRESTRequest | HoppGQLRequest requestIndex: string | number }) => { const { folderPath, request, requestIndex } = payload @@ -1767,8 +1841,12 @@ const addExample = (payload: { return } - // Additional validation for required request properties - if (!request.name && !request.endpoint) { + // Additional validation for required request properties — accept either the + // REST endpoint or the GQL url as proof we have a real request. + const hasUrl = isGQLRequest(request) + ? !!request.url + : !!(request as HoppRESTRequest).endpoint + if (!request.name && !hasUrl) { console.error("Request missing required properties:", request) toast.error(t("error.invalid_request")) return @@ -1802,6 +1880,13 @@ const onAddExample = async () => { return } + // GQL examples have a parallel-but-distinct shape (GQL originalRequest, + // no method/params/body). Handle them via the gql-example-response tab type. + if (isGQLRequest(request)) { + await addGQLExample(request, exampleName) + return + } + // Check if example name already exists if (request.responses && request.responses[exampleName]) { toast.error(t("response.duplicate_name_error")) @@ -1972,6 +2057,170 @@ const onAddExample = async () => { } } +/** + * GQL counterpart of the REST add-example flow inside `onAddExample`. Builds a + * `HoppGQLRequestResponse` with an empty body, persists it under the parent + * request's `responses` map, and opens it as a new `gql-example-response` tab. + * Persistence goes through the REST mutation in both personal and team paths + * because unified-workspace GQL bodies live in REST collection rows. + */ +const addGQLExample = async (request: HoppGQLRequest, exampleName: string) => { + if (request.responses && request.responses[exampleName]) { + toast.error(t("response.duplicate_name_error")) + return + } + + const originalRequest = makeHoppGQLResponseOriginalRequest({ + name: request.name, + url: request.url, + query: request.query, + variables: request.variables, + headers: request.headers, + auth: request.auth, + }) + + // Stamp the operation identity from the request's document (first + // operation — the one a run would execute) so the mock server can match + // this example; without stamps the matcher skips it entirely + let operationName: string | undefined + let operationType: string | undefined + try { + const operation = parseGQLDocument(request.query).definitions.find( + (definition): definition is OperationDefinitionNode => + definition.kind === "OperationDefinition" + ) + if (operation) { + operationType = operation.operation + operationName = operation.name?.value + } + } catch (_e) { + // Unparseable document — leave the example unstamped + } + + const newExample: HoppGQLRequestResponse = { + name: exampleName, + code: 200, + status: "OK", + headers: [], + body: "", + originalRequest, + ...(operationType ? { operationType } : {}), + ...(operationName ? { operationName } : {}), + } + + const newExampleID = Object.keys(request.responses ?? {}).length.toString() + + const updatedRequest: HoppGQLRequest = { + ...request, + responses: { + ...(request.responses ?? {}), + [exampleName]: newExample, + }, + } + + if (collectionsType.value.type === "my-collections") { + const folderPath = editingFolderPath.value + const requestIndex = editingRequestIndex.value + if (folderPath === null || requestIndex === null) return + + const isValidToken = await handleTokenValidation() + if (!isValidToken) return + + editRESTRequest(folderPath, requestIndex, updatedRequest) + toast.success(t("response.saved")) + + const possibleRequestActiveTab = tabs.getTabRefWithSaveContext({ + originLocation: "user-collection", + requestIndex, + folderPath, + }) + if ( + possibleRequestActiveTab && + possibleRequestActiveTab.value.document.type === "gql-request" + ) { + possibleRequestActiveTab.value.document.request.responses = + updatedRequest.responses + } + + displayModalAddExample(false) + + tabs.createNewTab({ + response: { ...cloneDeep(newExample), name: exampleName }, + isDirty: false, + type: "gql-example-response", + saveContext: { + originLocation: "user-collection", + folderPath, + requestIndex, + exampleID: newExampleID, + }, + inheritedProperties: cascadeParentCollectionForProperties( + folderPath, + "rest" + ), + }) + return + } + + if (hasTeamWriteAccess.value) { + if (!editingRequestID.value) return + modalLoadingState.value = true + + const data = { + requestID: editingRequestID.value, + data: { title: request.name, request: JSON.stringify(updatedRequest) }, + } + + pipe( + runMutation(UpdateRequestDocument, data), + TE.match( + (err: GQLError) => { + toast.error(`${getErrorMessage(err)}`) + modalLoadingState.value = false + }, + () => { + modalLoadingState.value = false + toast.success(t("response.saved")) + displayModalAddExample(false) + + const requestID = editingRequestID.value + const collectionID = editingFolderPath.value + if (!requestID) return + + const possibleRequestActiveTab = tabs.getTabRefWithSaveContext({ + originLocation: "team-collection", + requestID, + }) + if ( + possibleRequestActiveTab && + possibleRequestActiveTab.value.document.type === "gql-request" + ) { + possibleRequestActiveTab.value.document.request.responses = + updatedRequest.responses + } + + tabs.createNewTab({ + response: { ...cloneDeep(newExample), name: exampleName }, + isDirty: false, + type: "gql-example-response", + saveContext: { + originLocation: "team-collection", + requestID, + collectionID: collectionID ?? undefined, + exampleID: newExampleID, + }, + inheritedProperties: collectionID + ? teamCollectionService.cascadeParentCollectionForProperties( + collectionID + ) + : undefined, + }) + } + ) + )() + } +} + const removeCollection = (id: string) => { if (collectionsType.value.type === "my-collections") editingCollectionIndex.value = parseInt(id) @@ -2196,16 +2445,19 @@ const onRemoveRequest = async () => { requestIndex, }) - // If there is a tab attached to this request, dissociate its state and mark it dirty - if (possibleTab && possibleTab.value.document.type === "request") { - possibleTab.value.document.saveContext = null - possibleTab.value.document.isDirty = true - - // since the request is deleted, we need to remove the saved responses as well - possibleTab.value.document.request.responses = {} - - // remove inherited properties - possibleTab.value.document.inheritedProperties = undefined + if (possibleTab) { + const doc = possibleTab.value.document + if (doc.type === "request") { + doc.saveContext = null + doc.isDirty = true + doc.request.responses = {} + doc.inheritedProperties = undefined + } else if (doc.type === "gql-request") { + doc.saveContext = null + doc.isDirty = true + doc.request.responses = {} + doc.inheritedProperties = undefined + } } const requestToRemove = navigateToFolderWithIndexPath( @@ -2261,15 +2513,19 @@ const onRemoveRequest = async () => { requestID, }) - if (possibleTab && possibleTab.value.document.type === "request") { - possibleTab.value.document.saveContext = null - possibleTab.value.document.isDirty = true - - // since the request is deleted, we need to remove the saved responses as well - possibleTab.value.document.request.responses = {} - - // remove inherited properties - possibleTab.value.document.inheritedProperties = undefined + if (possibleTab) { + const doc = possibleTab.value.document + if (doc.type === "request") { + doc.saveContext = null + doc.isDirty = true + doc.request.responses = {} + doc.inheritedProperties = undefined + } else if (doc.type === "gql-request") { + doc.saveContext = null + doc.isDirty = true + doc.request.responses = {} + doc.inheritedProperties = undefined + } } } } @@ -2303,7 +2559,7 @@ const onRemoveResponse = async () => { delete request.responses[responseName] - const requestUpdated: HoppRESTRequest = { + const requestUpdated: HoppRESTRequest | HoppGQLRequest = { ...request, } @@ -2330,10 +2586,13 @@ const onRemoveResponse = async () => { folderPath, }) - // If there is a tab attached to this request, close it and set the active tab to the first one + // If there is a tab attached to this response (REST or GQL example), close + // it and set the active tab to the first one. if ( possibleActiveResponseTab && - possibleActiveResponseTab.value.document.type === "example-response" + (possibleActiveResponseTab.value.document.type === "example-response" || + possibleActiveResponseTab.value.document.type === + "gql-example-response") ) { const activeTabs = tabs.getActiveTabs() @@ -2355,13 +2614,8 @@ const onRemoveResponse = async () => { } } - // update the request tab responses if it's open - if ( - possibleRequestActiveTab && - possibleRequestActiveTab.value.document.type === "request" - ) { - possibleRequestActiveTab.value.document.request.responses = - requestUpdated.responses + if (possibleRequestActiveTab) { + setRequestTabResponses(possibleRequestActiveTab, requestUpdated.responses) } toast.success(t("state.deleted")) @@ -2404,10 +2658,13 @@ const onRemoveResponse = async () => { requestID, }) - // If there is a tab attached to this request, close it and set the active tab to the first one + // If there is a tab attached to this response (REST or GQL example), close + // it and set the active tab to the first one. if ( possibleActiveResponseTab && - possibleActiveResponseTab.value.document.type === "example-response" + (possibleActiveResponseTab.value.document.type === "example-response" || + possibleActiveResponseTab.value.document.type === + "gql-example-response") ) { const activeTabs = tabs.getActiveTabs() @@ -2429,13 +2686,8 @@ const onRemoveResponse = async () => { } } - // update the request tab responses if it's open - if ( - possibleRequestActiveTab && - possibleRequestActiveTab.value.document.type === "request" - ) { - possibleRequestActiveTab.value.document.request.responses = - requestUpdated.responses + if (possibleRequestActiveTab) { + setRequestTabResponses(possibleRequestActiveTab, requestUpdated.responses) } } } @@ -2450,7 +2702,7 @@ const selectPicked = (payload: Picked | null) => { * @param selectedRequest The request that the user clicked on emitted from the collection tree */ const selectRequest = (selectedRequest: { - request: HoppRESTRequest + request: HoppRESTRequest | HoppGQLRequest folderPath: string requestIndex: string isActive: boolean @@ -2474,17 +2726,34 @@ const selectRequest = (selectedRequest: { teamCollectionService.cascadeParentCollectionForProperties(folderPath) } + const isGql = isGQLRequest(request) + const possibleTab = tabs.getTabRefWithSaveContext({ originLocation: "team-collection", requestID: requestIndex, }) - if (possibleTab && possibleTab.value.document.type === "request") { + if (possibleTab) { tabs.setActiveTab(possibleTab.value.id) + } else if (isGql) { + tabs.createNewTab({ + type: "gql-request", + request: cloneDeep(request) as HoppGQLRequest, + isDirty: false, + cursorPosition: 0, + saveContext: { + originLocation: "team-collection", + requestID: requestIndex, + collectionID: folderPath, + exampleID: undefined, + requestRefID: request.id, + }, + inheritedProperties: inheritedProperties, + }) } else { tabs.createNewTab({ type: "request", - request: cloneDeep(request), + request: cloneDeep(request) as HoppRESTRequest, isDirty: false, saveContext: { originLocation: "team-collection", @@ -2497,6 +2766,8 @@ const selectRequest = (selectedRequest: { }) } } else { + const isGql = isGQLRequest(request) + possibleTab = tabs.getTabRefWithSaveContext({ originLocation: "user-collection", requestIndex: parseInt(requestIndex), @@ -2506,11 +2777,27 @@ const selectRequest = (selectedRequest: { if (possibleTab) { tabs.setActiveTab(possibleTab.value.id) + } else if (isGql) { + tabs.createNewTab({ + type: "gql-request", + request: cloneDeep(request) as HoppGQLRequest, + isDirty: false, + cursorPosition: 0, + saveContext: { + originLocation: "user-collection", + folderPath: folderPath!, + requestIndex: parseInt(requestIndex), + requestRefID: request._ref_id ?? request.id, + }, + inheritedProperties: cascadeParentCollectionForProperties( + folderPath, + "rest" + ), + }) } else { - // If not, open the request in a new tab tabs.createNewTab({ type: "request", - request: cloneDeep(request), + request: cloneDeep(request) as HoppRESTRequest, isDirty: false, saveContext: { originLocation: "user-collection", @@ -2531,12 +2818,82 @@ const selectResponse = (payload: { folderPath: string requestIndex: string responseName: string - request: HoppRESTRequest + request: HoppRESTRequest | HoppGQLRequest responseID: string }) => { const { folderPath, requestIndex, responseName, request, responseID } = payload + // GQL examples have their own tab document type (`gql-example-response`) + // backed by a GQL-shaped response payload; route there instead of the REST + // `example-response` path which renders REST-only components. + if (isGQLRequest(request)) { + const gqlResponse = request.responses[responseName] + if (!gqlResponse) return + + if (collectionsType.value.type === "my-collections") { + const possibleTab = tabs.getTabRefWithSaveContext({ + originLocation: "user-collection", + requestIndex: parseInt(requestIndex), + folderPath: folderPath!, + exampleID: responseID, + }) + + if (possibleTab) { + tabs.setActiveTab(possibleTab.value.id) + } else { + tabs.createNewTab({ + response: { + ...cloneDeep(gqlResponse), + name: responseName, + }, + isDirty: false, + type: "gql-example-response", + saveContext: { + originLocation: "user-collection", + folderPath: folderPath!, + requestIndex: parseInt(requestIndex), + exampleID: responseID, + }, + inheritedProperties: cascadeParentCollectionForProperties( + folderPath, + "rest" + ), + }) + } + } else { + const possibleTab = tabs.getTabRefWithSaveContext({ + originLocation: "team-collection", + requestID: requestIndex, + exampleID: responseID, + }) + + if (possibleTab) { + tabs.setActiveTab(possibleTab.value.id) + } else { + tabs.createNewTab({ + response: { + ...cloneDeep(gqlResponse), + name: responseName, + }, + isDirty: false, + type: "gql-example-response", + saveContext: { + originLocation: "team-collection", + requestID: requestIndex, + collectionID: folderPath, + exampleID: responseID, + }, + inheritedProperties: + teamCollectionService.cascadeParentCollectionForProperties( + folderPath + ), + }) + } + } + return + } + const response = request.responses[responseName] if (collectionsType.value.type === "my-collections") { @@ -2644,8 +3001,11 @@ const dropRequest = async (payload: { requestRefID, }) - // If there is a tab attached to this request, change save its save context - if (possibleTab && possibleTab.value.document.type === "request") { + if ( + possibleTab && + (possibleTab.value.document.type === "request" || + possibleTab.value.document.type === "gql-request") + ) { possibleTab.value.document.saveContext = { originLocation: "user-collection", folderPath: destinationCollectionIndex, @@ -2701,7 +3061,11 @@ const dropRequest = async (payload: { requestID: requestIndex, }) - if (possibleTab && possibleTab.value.document.type === "request") { + if ( + possibleTab && + (possibleTab.value.document.type === "request" || + possibleTab.value.document.type === "gql-request") + ) { possibleTab.value.document.saveContext = { originLocation: "team-collection", requestID: requestIndex, @@ -3352,7 +3716,11 @@ const doExportOpenAPI = async (format: "json" | "yaml") => { } } -const shareRequest = ({ request }: { request: HoppRESTRequest }) => { +const shareRequest = ({ + request, +}: { + request: HoppRESTRequest | HoppGQLRequest +}) => { if (currentUser.value) { // opens the share request modal invokeAction("share.request", { @@ -3836,6 +4204,7 @@ const getErrorMessage = (err: GQLError) => { case "team_req/requests_not_from_same_collection": return t("request.different_collection") case "team/team_collections_have_different_parents": + case "team_coll/not_same_parent": return t("collection.different_parent") default: return t("error.something_went_wrong") diff --git a/packages/hoppscotch-common/src/components/cookies/AllModal.vue b/packages/hoppscotch-common/src/components/cookies/AllModal.vue index 22de51691c7..522dd52dd62 100644 --- a/packages/hoppscotch-common/src/components/cookies/AllModal.vue +++ b/packages/hoppscotch-common/src/components/cookies/AllModal.vue @@ -89,6 +89,14 @@ :value="`${entry.name} => ${entry.value}`" readonly /> + + {{ t("cookies.modal.host_only") }} + import { PropType, ref, onMounted } from "vue" -import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data" +import { + Environment, + HoppCollection, + HoppGQLRequest, + HoppRESTRequest, +} from "@hoppscotch/data" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { useRouter, useRoute } from "vue-router" +type DocRequest = HoppRESTRequest | HoppGQLRequest + type DocumentationItem = { id: string type: "folder" | "request" - item: HoppCollection | HoppRESTRequest + item: HoppCollection | DocRequest inheritedProperties: HoppInheritedProperty } +const getRequestDescription = (req: DocRequest): string => req.description || "" + const props = defineProps({ collectionData: { type: Object as PropType, diff --git a/packages/hoppscotch-common/src/components/embeds/GQLIndex.vue b/packages/hoppscotch-common/src/components/embeds/GQLIndex.vue new file mode 100644 index 00000000000..8624f539a4d --- /dev/null +++ b/packages/hoppscotch-common/src/components/embeds/GQLIndex.vue @@ -0,0 +1,126 @@ + + + diff --git a/packages/hoppscotch-common/src/components/embeds/GQLRequest.vue b/packages/hoppscotch-common/src/components/embeds/GQLRequest.vue new file mode 100644 index 00000000000..5452ad02e92 --- /dev/null +++ b/packages/hoppscotch-common/src/components/embeds/GQLRequest.vue @@ -0,0 +1,127 @@ + + + diff --git a/packages/hoppscotch-common/src/components/embeds/Request.vue b/packages/hoppscotch-common/src/components/embeds/Request.vue index dea18bb7b43..24cf2a1cc58 100644 --- a/packages/hoppscotch-common/src/components/embeds/Request.vue +++ b/packages/hoppscotch-common/src/components/embeds/Request.vue @@ -55,7 +55,7 @@ import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils" import IconSave from "~icons/lucide/save" import { Ref } from "vue" -import { computed, useModel } from "vue" +import { computed, onBeforeUnmount, useModel } from "vue" import { ref } from "vue" import { useI18n } from "~/composables/i18n" import { useToast } from "~/composables/toast" @@ -64,7 +64,7 @@ import { useStreamSubscriber } from "~/composables/stream" import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" import { runRESTRequest$ } from "~/helpers/RequestRunner" import { HoppTab } from "~/services/tab" -import { HoppRequestDocument } from "~/helpers/rest/document" +import { HoppRequestDocument } from "~/helpers/tab/document" import { transformRequestVariablesToAggregateEnv } from "~/helpers/utils/environments" const toast = useToast() @@ -99,10 +99,16 @@ const newSendRequest = async () => { loading.value = true - const [cancel, streamPromise] = runRESTRequest$(tab) + // Viewer envs must not resolve into a shared request's execution + const [cancel, streamPromise] = runRESTRequest$(tab, { isolatedEnvs: true }) + // Store the cancel handle synchronously — `runRESTRequest$` returns it + // immediately, before the stream resolves. If we waited until after the + // `await` below, an unmount during that window would leave `onBeforeUnmount` + // with a null handle and leak the in-flight request. + requestCancelFunc.value = cancel + const streamResult = await streamPromise - requestCancelFunc.value = cancel if (E.isRight(streamResult)) { subscribeToStream( streamResult.right, @@ -186,4 +192,12 @@ const cancelRequest = () => { updateRESTResponse(null) } + +// Cancel any in-flight REST request when the embed iframe is destroyed +// (host navigation, SPA route change). Otherwise the runner stays +// subscribed to the response stream after the component is gone — small +// memory leak that grows if a user clicks between several embed links. +onBeforeUnmount(() => { + requestCancelFunc.value?.() +}) diff --git a/packages/hoppscotch-common/src/components/embeds/index.vue b/packages/hoppscotch-common/src/components/embeds/index.vue index f1460ab6a6d..230fe01e37f 100644 --- a/packages/hoppscotch-common/src/components/embeds/index.vue +++ b/packages/hoppscotch-common/src/components/embeds/index.vue @@ -14,11 +14,14 @@ :shared-request-u-r-l="sharedRequestURL" />
+
@@ -33,7 +36,7 @@ import { computed, useModel } from "vue" import { ref } from "vue" import { HoppTab } from "~/services/tab" -import { HoppRequestDocument } from "~/helpers/rest/document" +import { HoppRequestDocument } from "~/helpers/tab/document" import { platform } from "~/platform" import { RESTOptionTabs } from "../http/RequestOptions.vue" import { transformRequestVariablesToAggregateEnv } from "~/helpers/utils/environments" diff --git a/packages/hoppscotch-common/src/components/environments/Add.vue b/packages/hoppscotch-common/src/components/environments/Add.vue index 7f47f6d8b8b..bcdb49ca656 100644 --- a/packages/hoppscotch-common/src/components/environments/Add.vue +++ b/packages/hoppscotch-common/src/components/environments/Add.vue @@ -85,14 +85,14 @@ import { updateEnvironment, } from "~/newstore/environments" import { CurrentValueService } from "~/services/current-environment-value.service" -import { RESTTabService } from "~/services/tab/rest" +import { WorkspaceTabsService } from "~/services/tab/workspace-tabs" import { Scope } from "./Selector.vue" import { GlobalEnvironment } from "@hoppscotch/data" const t = useI18n() const toast = useToast() -const tabs = useService(RESTTabService) +const tabs = useService(WorkspaceTabsService) const currentEnvironmentValueService = useService(CurrentValueService) const props = defineProps<{ @@ -239,12 +239,18 @@ const addEnvironment = async () => { if (replaceWithVariable.value) { //replace the current tab endpoint with the variable name with << and >> const variableName = `<<${editingName.value}>>` - //replace the currenttab endpoint containing the value in the text with variablename - tabs.currentActiveTab.value.document.request.endpoint = - tabs.currentActiveTab.value.document.request.endpoint.replace( + const doc = tabs.currentActiveTab.value.document + if (doc.type === "request") { + doc.request.endpoint = doc.request.endpoint.replace( editingValue.value, variableName ) + } else if (doc.type === "gql-request") { + doc.request.url = doc.request.url.replace( + editingValue.value, + variableName + ) + } } hideModal() diff --git a/packages/hoppscotch-common/src/components/environments/index.vue b/packages/hoppscotch-common/src/components/environments/index.vue index 5f36045284d..29de810d6bf 100644 --- a/packages/hoppscotch-common/src/components/environments/index.vue +++ b/packages/hoppscotch-common/src/components/environments/index.vue @@ -165,33 +165,18 @@ const updateEnvironmentType = (newEnvironmentType: EnvironmentType) => { const workspace = workspaceService.currentWorkspace -// Switch to my environments if workspace is personal and to team environments if workspace is team -// also resets selected environment if workspace is personal and the previous selected environment was a team environment +// Switch to my environments if workspace is personal and to team +// environments if workspace is team. Resetting a stale team-env selection +// is handled by WorkspaceService.changeWorkspace — the single funnel every +// workspace switch goes through, mounted or not. watch( workspace, (newWorkspace) => { - const { type: newWorkspaceType } = newWorkspace - - if (newWorkspaceType === "personal") { + if (newWorkspace.type === "personal") { switchToMyEnvironments() } else { updateSelectedTeam(newWorkspace) } - - const newTeamID = - newWorkspaceType === "team" ? newWorkspace.teamID : undefined - - // Set active environment to the `No environment` state - // if navigating away from a team workspace - if ( - selectedEnvironmentIndex.value.type === "TEAM_ENV" && - newTeamID && - selectedEnvironmentIndex.value.teamID !== newTeamID - ) { - setSelectedEnvironmentIndex({ - type: "NO_ENV_SELECTED", - }) - } }, { immediate: true } ) diff --git a/packages/hoppscotch-common/src/components/gql/Argument.vue b/packages/hoppscotch-common/src/components/gql/Argument.vue new file mode 100644 index 00000000000..e1f85abf98d --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Argument.vue @@ -0,0 +1,103 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Arguments.vue b/packages/hoppscotch-common/src/components/gql/Arguments.vue new file mode 100644 index 00000000000..a3754033d20 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Arguments.vue @@ -0,0 +1,32 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Authorization.vue b/packages/hoppscotch-common/src/components/gql/Authorization.vue new file mode 100644 index 00000000000..e6f997180e0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Authorization.vue @@ -0,0 +1,337 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/DefaultValue.vue b/packages/hoppscotch-common/src/components/gql/DefaultValue.vue new file mode 100644 index 00000000000..eb06d8594ab --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/DefaultValue.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Directives.vue b/packages/hoppscotch-common/src/components/gql/Directives.vue new file mode 100644 index 00000000000..52363fa5f4a --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Directives.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/DocExplorer.vue b/packages/hoppscotch-common/src/components/gql/DocExplorer.vue new file mode 100644 index 00000000000..26f2e11481b --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/DocExplorer.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/gql/EnumValues.vue b/packages/hoppscotch-common/src/components/gql/EnumValues.vue new file mode 100644 index 00000000000..b2fdc61869e --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/EnumValues.vue @@ -0,0 +1,95 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/ExplorerSection.vue b/packages/hoppscotch-common/src/components/gql/ExplorerSection.vue new file mode 100644 index 00000000000..c7b5a511f8a --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/ExplorerSection.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Field.vue b/packages/hoppscotch-common/src/components/gql/Field.vue new file mode 100644 index 00000000000..89a2561b82c --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Field.vue @@ -0,0 +1,78 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/FieldDocumentation.vue b/packages/hoppscotch-common/src/components/gql/FieldDocumentation.vue new file mode 100644 index 00000000000..be938775862 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/FieldDocumentation.vue @@ -0,0 +1,61 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/FieldLink.vue b/packages/hoppscotch-common/src/components/gql/FieldLink.vue new file mode 100644 index 00000000000..2d96250a6ed --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/FieldLink.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Fields.vue b/packages/hoppscotch-common/src/components/gql/Fields.vue new file mode 100644 index 00000000000..6d2bc425fab --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Fields.vue @@ -0,0 +1,49 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Headers.vue b/packages/hoppscotch-common/src/components/gql/Headers.vue new file mode 100644 index 00000000000..025efe7cd69 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Headers.vue @@ -0,0 +1,718 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/ImplementsInterfaces.vue b/packages/hoppscotch-common/src/components/gql/ImplementsInterfaces.vue new file mode 100644 index 00000000000..310a0c62583 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/ImplementsInterfaces.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Query.vue b/packages/hoppscotch-common/src/components/gql/Query.vue new file mode 100644 index 00000000000..16268d4d6d3 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Query.vue @@ -0,0 +1,300 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Request.vue b/packages/hoppscotch-common/src/components/gql/Request.vue new file mode 100644 index 00000000000..6f793db9f62 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Request.vue @@ -0,0 +1,297 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/RequestOptions.vue b/packages/hoppscotch-common/src/components/gql/RequestOptions.vue new file mode 100644 index 00000000000..da05931e5d6 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/RequestOptions.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/gql/RequestTab.vue b/packages/hoppscotch-common/src/components/gql/RequestTab.vue new file mode 100644 index 00000000000..5f6d317ddc6 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/RequestTab.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Response.vue b/packages/hoppscotch-common/src/components/gql/Response.vue new file mode 100644 index 00000000000..3a92e707e9e --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Response.vue @@ -0,0 +1,498 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/ResponseMeta.vue b/packages/hoppscotch-common/src/components/gql/ResponseMeta.vue new file mode 100644 index 00000000000..dcd140d8a50 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/ResponseMeta.vue @@ -0,0 +1,182 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Schema.vue b/packages/hoppscotch-common/src/components/gql/Schema.vue new file mode 100644 index 00000000000..f61b6f15aa6 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Schema.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/gql/SchemaDocumentation.vue b/packages/hoppscotch-common/src/components/gql/SchemaDocumentation.vue new file mode 100644 index 00000000000..ba3b42cf8d9 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/SchemaDocumentation.vue @@ -0,0 +1,106 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/SchemaSearch.vue b/packages/hoppscotch-common/src/components/gql/SchemaSearch.vue new file mode 100644 index 00000000000..7169a0d2cfb --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/SchemaSearch.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/gql/SubscriptionLog.vue b/packages/hoppscotch-common/src/components/gql/SubscriptionLog.vue new file mode 100644 index 00000000000..a9f14852f40 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/SubscriptionLog.vue @@ -0,0 +1,133 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/TabHead.vue b/packages/hoppscotch-common/src/components/gql/TabHead.vue new file mode 100644 index 00000000000..585a8e0b623 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/TabHead.vue @@ -0,0 +1,164 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/TypeDocumentation.vue b/packages/hoppscotch-common/src/components/gql/TypeDocumentation.vue new file mode 100644 index 00000000000..6b1ed1e2e26 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/TypeDocumentation.vue @@ -0,0 +1,23 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/TypeLink.vue b/packages/hoppscotch-common/src/components/gql/TypeLink.vue new file mode 100644 index 00000000000..7a14bec0a77 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/TypeLink.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/Variable.vue b/packages/hoppscotch-common/src/components/gql/Variable.vue new file mode 100644 index 00000000000..e85aa80ced5 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/Variable.vue @@ -0,0 +1,180 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/example/Response.vue b/packages/hoppscotch-common/src/components/gql/example/Response.vue new file mode 100644 index 00000000000..9f00bc71acd --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/example/Response.vue @@ -0,0 +1,164 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/example/ResponseRequest.vue b/packages/hoppscotch-common/src/components/gql/example/ResponseRequest.vue new file mode 100644 index 00000000000..12b74019b4c --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/example/ResponseRequest.vue @@ -0,0 +1,174 @@ + + + diff --git a/packages/hoppscotch-common/src/components/gql/example/ResponseTab.vue b/packages/hoppscotch-common/src/components/gql/example/ResponseTab.vue new file mode 100644 index 00000000000..0dbb394e092 --- /dev/null +++ b/packages/hoppscotch-common/src/components/gql/example/ResponseTab.vue @@ -0,0 +1,48 @@ + + + diff --git a/packages/hoppscotch-common/src/components/graphql/Authorization.vue b/packages/hoppscotch-common/src/components/graphql/Authorization.vue index a19ff787ee6..bab059beb76 100644 --- a/packages/hoppscotch-common/src/components/graphql/Authorization.vue +++ b/packages/hoppscotch-common/src/components/graphql/Authorization.vue @@ -238,8 +238,7 @@ const selectOAuth2AuthType = () => { // @ts-expect-error - the existing grantTypeInfo might be in the auth object, typescript doesnt know that const existingGrantTypeInfo = auth.value.grantTypeInfo as - | HoppGQLAuthOAuth2["grantTypeInfo"] - | undefined + HoppGQLAuthOAuth2["grantTypeInfo"] | undefined const grantTypeInfo = existingGrantTypeInfo ? existingGrantTypeInfo diff --git a/packages/hoppscotch-common/src/components/graphql/Headers.vue b/packages/hoppscotch-common/src/components/graphql/Headers.vue index fc16f3d0e02..f673e53ab93 100644 --- a/packages/hoppscotch-common/src/components/graphql/Headers.vue +++ b/packages/hoppscotch-common/src/components/graphql/Headers.vue @@ -478,12 +478,10 @@ const updateHeader = (index: number, header: GQLHeader & { id: number }) => { const deleteHeader = (index: number) => { const headersBeforeDeletion = clone(workingHeaders.value) - if ( - !( - headersBeforeDeletion.length > 0 && - index === headersBeforeDeletion.length - 1 - ) - ) { + if (!( + headersBeforeDeletion.length > 0 && + index === headersBeforeDeletion.length - 1 + )) { if (deletionToast.value) { deletionToast.value.goAway(0) deletionToast.value = null @@ -699,4 +697,9 @@ const mask = (header: any) => { } const changeTab = () => emit("change-tab", "authorization") + +// No inspection wiring here: this component serves the legacy /graphql page, +// whose tabs live in GQLTabService — the InspectionService only inspects the +// unified workspace's tabs, so reading its results here would show warnings +// belonging to a different page's active tab. diff --git a/packages/hoppscotch-common/src/components/graphql/Request.vue b/packages/hoppscotch-common/src/components/graphql/Request.vue index ce06d5cf513..5993cdad408 100644 --- a/packages/hoppscotch-common/src/components/graphql/Request.vue +++ b/packages/hoppscotch-common/src/components/graphql/Request.vue @@ -65,9 +65,7 @@ import { platform } from "~/platform" import { useI18n } from "@composables/i18n" import { computed, ref, watch } from "vue" -import { connection } from "~/helpers/graphql/connection" -import { connect } from "~/helpers/graphql/connection" -import { disconnect } from "~/helpers/graphql/connection" +import { connection, connect, disconnect } from "~/helpers/graphql/connection" import { KernelInterceptorService } from "~/services/kernel-interceptor.service" import { useService } from "dioc/vue" import { defineActionHandler } from "~/helpers/actions" diff --git a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue index 991cc8f57ff..2266a0f74cb 100644 --- a/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue +++ b/packages/hoppscotch-common/src/components/graphql/RequestOptions.vue @@ -48,7 +48,7 @@ @@ -82,6 +82,10 @@ const _VALID_GQL_OPERATIONS = [ "headers", "variables", "authorization", + // Script tabs exist only on the unified workspace's gql/RequestOptions — + // this legacy page doesn't render them, but it owns the GQLOptionTabs type + "preRequestScript", + "tests", ] as const export type GQLOptionTabs = (typeof _VALID_GQL_OPERATIONS)[number] diff --git a/packages/hoppscotch-common/src/components/graphql/Response.vue b/packages/hoppscotch-common/src/components/graphql/Response.vue index a3be45c9a6f..bd5b2daac40 100644 --- a/packages/hoppscotch-common/src/components/graphql/Response.vue +++ b/packages/hoppscotch-common/src/components/graphql/Response.vue @@ -1,6 +1,6 @@