diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 150f5852..70046f99 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -892,6 +892,13 @@ export const MOCK_SERVER_NOT_FOUND = 'mock_server/not_found'; */ export const MOCK_SERVER_INVALID_COLLECTION = 'mock_server/invalid_collection'; +/** + * Mock server collection creation failed + * (MockServerService) + */ +export const MOCK_SERVER_COLLECTION_CREATION_FAILED = + 'mock_server/collection_creation_failed'; + /** * Mock server already exists for this collection * (MockServerService) diff --git a/packages/hoppscotch-backend/src/mock-server/constants/mock-server-coll-request-example.ts b/packages/hoppscotch-backend/src/mock-server/constants/mock-server-coll-request-example.ts new file mode 100644 index 00000000..6a0412b9 --- /dev/null +++ b/packages/hoppscotch-backend/src/mock-server/constants/mock-server-coll-request-example.ts @@ -0,0 +1,781 @@ +import { randomUUID } from 'crypto'; + +const generateRefId = () => `${Date.now().toString(36)}_${randomUUID()}`; + +export const mockServerCollRequestExample = ( + collectionName: string = 'Hoppscotch API Mock example', +) => { + const baseEnv = '<>'; + return [ + { + v: 10, + name: collectionName, + folders: [], + requests: [ + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'addPet', + method: 'POST', + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + preRequestScript: '', + testScript: '', + requestVariables: [], + responses: { + 'Invalid input': { + name: 'Invalid input', + status: 'Method Not Allowed', + code: 405, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'addPet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + method: 'POST', + requestVariables: [], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'updatePet', + method: 'PUT', + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + preRequestScript: '', + testScript: '', + requestVariables: [], + responses: { + 'Invalid ID supplied': { + name: 'Invalid ID supplied', + status: 'Bad Request', + code: 400, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'updatePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + method: 'PUT', + requestVariables: [], + }, + }, + 'Pet not found': { + name: 'Pet not found', + status: 'Not Found', + code: 404, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'updatePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + method: 'PUT', + requestVariables: [], + }, + }, + 'Validation exception': { + name: 'Validation exception', + status: 'Method Not Allowed', + code: 405, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'updatePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/json', + body: '{\n\t"id": 1,\n\t"category": {\n\t\t"id": 1,\n\t\t"name": "string"\n\t},\n\t"name": "doggie",\n\t"photoUrls": [\n\t\t"string"\n\t],\n\t"tags": [],\n\t"status": "available"\n}', + }, + endpoint: baseEnv + '/v2/pet', + params: [], + headers: [], + method: 'PUT', + requestVariables: [], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'findPetsByStatus', + method: 'GET', + endpoint: baseEnv + '/v2/pet/findByStatus', + params: [ + { + key: 'status', + value: '', + active: true, + description: + 'Status values that need to be considered for filter', + }, + ], + headers: [], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + preRequestScript: '', + testScript: '', + requestVariables: [], + responses: { + 'successful operation': { + name: 'successful operation', + status: 'OK', + code: 200, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'findPetsByStatus', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/findByStatus', + params: [ + { + key: 'status', + value: '', + active: true, + description: + 'Status values that need to be considered for filter', + }, + ], + headers: [], + method: 'GET', + requestVariables: [], + }, + }, + 'Invalid status value': { + name: 'Invalid status value', + status: 'Bad Request', + code: 400, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'findPetsByStatus', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/findByStatus', + params: [ + { + key: 'status', + value: '', + active: true, + description: + 'Status values that need to be considered for filter', + }, + ], + headers: [], + method: 'GET', + requestVariables: [], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'getPetById', + method: 'GET', + endpoint: baseEnv + '/v2/pet/<>', + params: [], + headers: [], + auth: { + authType: 'api-key', + addTo: 'HEADERS', + authActive: true, + key: 'api_key', + value: '', + }, + body: { + contentType: null, + body: null, + }, + preRequestScript: '', + testScript: '', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + responses: { + 'successful operation': { + name: 'successful operation', + status: 'OK', + code: 200, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'getPetById', + auth: { + authType: 'api-key', + addTo: 'HEADERS', + authActive: true, + key: 'api_key', + value: '', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [], + method: 'GET', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + 'Invalid ID supplied': { + name: 'Invalid ID supplied', + status: 'Bad Request', + code: 400, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'getPetById', + auth: { + authType: 'api-key', + addTo: 'HEADERS', + authActive: true, + key: 'api_key', + value: '', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [], + method: 'GET', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + 'Pet not found': { + name: 'Pet not found', + status: 'Not Found', + code: 404, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'getPetById', + auth: { + authType: 'api-key', + addTo: 'HEADERS', + authActive: true, + key: 'api_key', + value: '', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [], + method: 'GET', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'updatePetWithForm', + method: 'POST', + endpoint: baseEnv + '/v2/pet/<>', + params: [], + headers: [], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/x-www-form-urlencoded', + body: 'name: \nstatus: ', + }, + preRequestScript: '', + testScript: '', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + responses: { + 'Invalid input': { + name: 'Invalid input', + status: 'Method Not Allowed', + code: 405, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'updatePetWithForm', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: 'application/x-www-form-urlencoded', + body: 'name: \nstatus: ', + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [], + method: 'POST', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + }, + }, + { + v: '16', + _ref_id: `req_${generateRefId()}`, + name: 'deletePet', + method: 'DELETE', + endpoint: baseEnv + '/v2/pet/<>', + params: [], + headers: [ + { + key: 'api_key', + value: '', + active: true, + description: '', + }, + ], + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + preRequestScript: '', + testScript: '', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + responses: { + 'Invalid ID supplied': { + name: 'Invalid ID supplied', + status: 'Bad Request', + code: 400, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'deletePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [ + { + key: 'api_key', + value: '', + active: true, + description: '', + }, + ], + method: 'DELETE', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + 'Pet not found': { + name: 'Pet not found', + status: 'Not Found', + code: 404, + headers: [ + { + key: 'content-type', + value: 'application/json', + description: '', + active: true, + }, + ], + body: '', + originalRequest: { + v: '6', + name: 'deletePet', + auth: { + authType: 'oauth-2', + authActive: true, + grantTypeInfo: { + authEndpoint: baseEnv + '/oauth/authorize', + clientID: '', + grantType: 'IMPLICIT', + scopes: 'write:pets read:pets', + token: '', + authRequestParams: [], + refreshRequestParams: [], + }, + addTo: 'HEADERS', + }, + body: { + contentType: null, + body: null, + }, + endpoint: 'petstore.swagger.io/v2/pet/<>', + params: [], + headers: [ + { + key: 'api_key', + value: '', + active: true, + description: '', + }, + ], + method: 'DELETE', + requestVariables: [ + { + key: 'petId', + value: '', + active: true, + }, + ], + }, + }, + }, + }, + ], + data: { + auth: { + authType: 'inherit', + authActive: true, + }, + headers: [], + _ref_id: `coll_${generateRefId()}`, + }, + }, + ]; +}; diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts index 8cf79e65..69c5beb1 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.model.ts @@ -118,10 +118,25 @@ export class CreateMockServerInput { name: string; @Field({ + nullable: true, description: 'ID of the (team or user) collection to associate with the mock server', }) - collectionID: string; + collectionID?: string; + + @Field({ + nullable: true, + description: + 'Whether to auto-create a collection for the mock server if collectionID is not provided', + }) + autoCreateCollection?: boolean; + + @Field({ + nullable: true, + description: + 'Whether to auto-create request examples in the collection for the mock server', + }) + autoCreateRequestExample?: boolean; @Field(() => WorkspaceType, { description: 'Type of workspace: USER or TEAM', diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts index c288d2c9..fa100fff 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.module.ts @@ -8,9 +8,18 @@ import { TeamModule } from 'src/team/team.module'; import { TeamRequestModule } from 'src/team-request/team-request.module'; import { MockServerController } from './mock-server.controller'; import { AccessTokenModule } from 'src/access-token/access-token.module'; +import { TeamCollectionModule } from 'src/team-collection/team-collection.module'; +import { UserCollectionModule } from 'src/user-collection/user-collection.module'; @Module({ - imports: [PrismaModule, TeamModule, TeamRequestModule, AccessTokenModule], + imports: [ + PrismaModule, + UserCollectionModule, + TeamModule, + TeamCollectionModule, + TeamRequestModule, + AccessTokenModule, + ], controllers: [MockServerController], providers: [ MockServerService, diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts index 149c6769..64e2595c 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts @@ -26,6 +26,8 @@ import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; import { TeamAccessRole } from 'src/team/team.model'; import { throwErr } from 'src/utils'; +import { AuthUser } from 'src/types/AuthUser'; +import { INVALID_PARAMS } from 'src/errors'; @Resolver(() => MockServer) export class MockServerResolver { @@ -72,7 +74,7 @@ export class MockServerResolver { }) @UseGuards(GqlAuthGuard) async myMockServers( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args() args: OffsetPaginationArgs, ): Promise { return this.mockServerService.getUserMockServers(user.uid, args); @@ -104,7 +106,7 @@ export class MockServerResolver { }) @UseGuards(GqlAuthGuard) async mockServer( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args({ name: 'id', type: () => ID, @@ -124,7 +126,7 @@ export class MockServerResolver { }) @UseGuards(GqlAuthGuard) async mockServerLogs( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args({ name: 'mockServerID', type: () => ID, @@ -151,8 +153,15 @@ export class MockServerResolver { @UseGuards(GqlAuthGuard) async createMockServer( @Args('input') input: CreateMockServerInput, - @GqlUser() user: User, + @GqlUser() user: AuthUser, ): Promise { + if ( + (input.collectionID && input.autoCreateCollection) || + (!input.collectionID && !input.autoCreateCollection) + ) { + throwErr(INVALID_PARAMS); + } + const result = await this.mockServerService.createMockServer(user, input); if (E.isLeft(result)) throwErr(result.left); @@ -164,7 +173,7 @@ export class MockServerResolver { }) @UseGuards(GqlAuthGuard) async updateMockServer( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args() args: MockServerMutationArgs, @Args('input') input: UpdateMockServerInput, ): Promise { @@ -183,7 +192,7 @@ export class MockServerResolver { }) @UseGuards(GqlAuthGuard) async deleteMockServer( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args() args: MockServerMutationArgs, ): Promise { const result = await this.mockServerService.deleteMockServer( @@ -200,7 +209,7 @@ export class MockServerResolver { }) @UseGuards(GqlAuthGuard) async deleteMockServerLog( - @GqlUser() user: User, + @GqlUser() user: AuthUser, @Args({ name: 'logID', type: () => ID, 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 2ad9ed2d..c7489382 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 @@ -2,6 +2,8 @@ import { MockServerService } from './mock-server.service'; import { PrismaService } from '../prisma/prisma.service'; import { ConfigService } from '@nestjs/config'; import { MockServerAnalyticsService } from './mock-server-analytics.service'; +import { TeamCollectionService } from '../team-collection/team-collection.service'; +import { UserCollectionService } from '../user-collection/user-collection.service'; import { mockDeep, mockReset } from 'jest-mock-extended'; import * as E from 'fp-ts/Either'; import { @@ -17,9 +19,9 @@ import { UserCollection, TeamCollection, UserRequest, + User, } from 'src/generated/prisma/client'; import { WorkspaceType } from '../types/WorkspaceTypes'; -import { User } from '../user/user.model'; import { CreateMockServerInput, UpdateMockServerInput, @@ -28,17 +30,23 @@ import { const mockPrisma = mockDeep(); const mockAnalyticsService = mockDeep(); const mockConfigService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockUserCollectionService = mockDeep(); const mockServerService = new MockServerService( - mockAnalyticsService, - mockPrisma, mockConfigService, + mockPrisma, + mockAnalyticsService, + mockTeamCollectionService, + mockUserCollectionService, ); beforeEach(() => { mockReset(mockPrisma); mockReset(mockAnalyticsService); mockReset(mockConfigService); + mockReset(mockTeamCollectionService); + mockReset(mockUserCollectionService); // Default config values mockConfigService.get.mockImplementation((key: string) => { @@ -57,6 +65,7 @@ const user: User = { email: 'test@example.com', photoURL: null, isAdmin: false, + refreshToken: null, currentGQLSession: '{}', currentRESTSession: '{}', createdOn: currentTime, @@ -471,6 +480,282 @@ describe('MockServerService', () => { expect(result.left).toBe('mock_server/creation_failed'); } }); + + describe('auto-create collection', () => { + test('should auto-create user collection without request example', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Auto Mock Server', + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + const createdCollection = { ...userCollection, id: 'new-coll-123' }; + mockUserCollectionService.createUserCollection.mockResolvedValue( + E.right(createdCollection as any), + ); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + collectionID: 'new-coll-123', + }); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockUserCollectionService.createUserCollection).toHaveBeenCalledWith( + user, + autoCreateInput.name, + null, + null, + 'REST', + ); + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + collectionID: 'new-coll-123', + }), + }), + ); + }); + + test('should auto-create user collection with request example', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Auto Mock Server', + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: true, + }; + + mockUserCollectionService.importCollectionsFromJSON.mockResolvedValue( + E.right({ + exportedCollection: JSON.stringify([{ id: 'imported-coll-123' }]), + } as any), + ); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + collectionID: 'imported-coll-123', + }); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockUserCollectionService.importCollectionsFromJSON).toHaveBeenCalled(); + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + collectionID: 'imported-coll-123', + }), + }), + ); + }); + + test('should auto-create team collection without request example', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Team Auto Mock', + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + const createdTeamColl = { ...teamCollection, id: 'new-team-coll-123' }; + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockTeamCollectionService.createCollection.mockResolvedValue( + E.right(createdTeamColl as any), + ); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + collectionID: 'new-team-coll-123', + }); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockTeamCollectionService.createCollection).toHaveBeenCalledWith( + 'team123', + autoCreateInput.name, + null, + null, + ); + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + collectionID: 'new-team-coll-123', + }), + }), + ); + }); + + test('should auto-create team collection with request example', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Team Auto Mock', + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: true, + }; + + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockTeamCollectionService.importCollectionsFromJSON.mockResolvedValue( + E.right([{ id: 'imported-team-coll-123' }] as any), + ); + mockPrisma.mockServer.create.mockResolvedValue({ + ...dbMockServer, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + collectionID: 'imported-team-coll-123', + }); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isRight(result)).toBe(true); + expect(mockTeamCollectionService.importCollectionsFromJSON).toHaveBeenCalled(); + expect(mockPrisma.mockServer.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + collectionID: 'imported-team-coll-123', + }), + }), + ); + }); + + test('should return error when auto-create user collection fails', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Auto Mock Server', + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + mockUserCollectionService.createUserCollection.mockResolvedValue( + E.left('user_collection/creation_failed'), + ); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe('user_collection/creation_failed'); + } + }); + + test('should return error when auto-create team collection fails', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Team Auto Mock', + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockTeamCollectionService.createCollection.mockResolvedValue( + E.left('team_coll/short_title'), + ); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isLeft(result)).toBe(true); + if (E.isLeft(result)) { + expect(result.left).toBe('team_coll/short_title'); + } + }); + + test('should rollback collection on mock server creation failure', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Auto Mock Server', + workspaceType: WorkspaceType.USER, + workspaceID: undefined, + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + const createdCollection = { ...userCollection, id: 'rollback-coll-123' }; + mockUserCollectionService.createUserCollection.mockResolvedValue( + E.right(createdCollection as any), + ); + mockPrisma.mockServer.create.mockRejectedValue( + new Error('Database error'), + ); + mockUserCollectionService.deleteUserCollection.mockResolvedValue( + E.right(true), + ); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isLeft(result)).toBe(true); + expect(mockUserCollectionService.deleteUserCollection).toHaveBeenCalledWith( + 'rollback-coll-123', + user.uid, + ); + }); + + test('should rollback team collection on mock server creation failure', async () => { + const autoCreateInput: CreateMockServerInput = { + name: 'Team Auto Mock', + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team123', + delayInMs: 0, + autoCreateCollection: true, + autoCreateRequestExample: false, + }; + + const createdTeamColl = { ...teamCollection, id: 'rollback-team-coll-123' }; + mockPrisma.team.findFirst.mockResolvedValue({ id: 'team123' } as any); + mockTeamCollectionService.createCollection.mockResolvedValue( + E.right(createdTeamColl as any), + ); + mockPrisma.mockServer.create.mockRejectedValue( + new Error('Database error'), + ); + mockTeamCollectionService.deleteCollection.mockResolvedValue( + E.right(true), + ); + + const result = await mockServerService.createMockServer( + user, + autoCreateInput, + ); + + expect(E.isLeft(result)).toBe(true); + expect(mockTeamCollectionService.deleteCollection).toHaveBeenCalledWith( + 'rollback-team-coll-123', + ); + }); + }); }); describe('updateMockServer', () => { 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 52340138..bb357be5 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.service.ts @@ -8,7 +8,6 @@ import { MockServerCollection, MockServerLog, } from './mock-server.model'; -import { User } from 'src/user/user.model'; import * as E from 'fp-ts/Either'; import { MOCK_SERVER_NOT_FOUND, @@ -19,6 +18,7 @@ import { MOCK_SERVER_DELETION_FAILED, MOCK_SERVER_LOG_NOT_FOUND, MOCK_SERVER_LOG_DELETION_FAILED, + MOCK_SERVER_COLLECTION_CREATION_FAILED, } from 'src/errors'; import { randomBytes } from 'crypto'; import { WorkspaceType } from 'src/types/WorkspaceTypes'; @@ -31,13 +31,20 @@ import { OffsetPaginationArgs } from 'src/types/input-types.args'; import { ConfigService } from '@nestjs/config'; import { MockServerAnalyticsService } from './mock-server-analytics.service'; import { PrismaError } from 'src/prisma/prisma-error-codes'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; +import { ReqType } from 'src/types/RequestTypes'; +import { AuthUser } from 'src/types/AuthUser'; +import { mockServerCollRequestExample } from './constants/mock-server-coll-request-example'; @Injectable() export class MockServerService { constructor( - private readonly mockServerAnalyticsService: MockServerAnalyticsService, - private readonly prisma: PrismaService, private readonly configService: ConfigService, + private readonly prisma: PrismaService, + private readonly mockServerAnalyticsService: MockServerAnalyticsService, + private readonly teamCollectionService: TeamCollectionService, + private readonly userCollectionService: UserCollectionService, ) {} /** @@ -252,7 +259,10 @@ export class MockServerService { /** * Validate workspace access permission and existence */ - private async validateWorkspace(user: User, input: CreateMockServerInput) { + private async validateWorkspace( + user: AuthUser, + input: CreateMockServerInput, + ) { if (input.workspaceType === WorkspaceType.TEAM) { if (!input.workspaceID) return E.left(TEAM_INVALID_ID); @@ -271,7 +281,12 @@ export class MockServerService { /** * Validate collection exists and user has access */ - private async validateCollection(user: User, input: CreateMockServerInput) { + private async validateCollection( + user: AuthUser, + input: CreateMockServerInput, + ) { + if (!input.collectionID) return E.left(MOCK_SERVER_INVALID_COLLECTION); + if (input.workspaceType === WorkspaceType.TEAM) { const collection = await this.prisma.teamCollection.findUnique({ where: { id: input.collectionID, teamID: input.workspaceID }, @@ -291,24 +306,105 @@ export class MockServerService { return E.left(MOCK_SERVER_INVALID_COLLECTION); } + private async createAutoCollection( + user: AuthUser, + input: CreateMockServerInput, + ) { + if (input.workspaceType === WorkspaceType.USER) { + if (!input.autoCreateRequestExample) { + // create only a collection + const userColl = await this.userCollectionService.createUserCollection( + user, + input.name, + null, + null, + ReqType.REST, + ); + + if (E.isLeft(userColl)) return E.left(userColl.left); + return E.right({ id: userColl.right.id }); + } else { + // create collection with a request example + const importedUserColl = + await this.userCollectionService.importCollectionsFromJSON( + JSON.stringify(mockServerCollRequestExample(input.name)), + user.uid, + null, + ReqType.REST, + ); + if (E.isLeft(importedUserColl)) return E.left(importedUserColl.left); + if (JSON.parse(importedUserColl.right.exportedCollection).length === 0) + return E.left(MOCK_SERVER_COLLECTION_CREATION_FAILED); + + return E.right({ + id: JSON.parse(importedUserColl.right.exportedCollection)[0].id, + }); + } + } else if (input.workspaceType === WorkspaceType.TEAM) { + if (!input.workspaceID) return E.left(TEAM_INVALID_ID); + + if (!input.autoCreateRequestExample) { + const teamColl = await this.teamCollectionService.createCollection( + input.workspaceID, + input.name, + null, + null, + ); + + if (E.isLeft(teamColl)) return E.left(teamColl.left); + return E.right({ id: teamColl.right.id }); + } else { + const importedTeamColl = + await this.teamCollectionService.importCollectionsFromJSON( + JSON.stringify(mockServerCollRequestExample(input.name)), + input.workspaceID, + null, + ); + + if (E.isLeft(importedTeamColl)) return E.left(importedTeamColl.left); + if (importedTeamColl.right.length === 0) + return E.left(MOCK_SERVER_COLLECTION_CREATION_FAILED); + + return E.right({ + id: importedTeamColl.right[0].id, + }); + } + } + + return E.left(MOCK_SERVER_COLLECTION_CREATION_FAILED); + } + /** * Create a new mock server */ async createMockServer( - user: User, + user: AuthUser, input: CreateMockServerInput, ): Promise> { + let collectionID: string | undefined = input.collectionID; try { // Validate workspace type and ID + const workspaceValidation = await this.validateWorkspace(user, input); if (E.isLeft(workspaceValidation)) { return E.left(workspaceValidation.left); } - // Validate collection exists and user has access - const collectionValidation = await this.validateCollection(user, input); - if (E.isLeft(collectionValidation)) { - return E.left(collectionValidation.left); + if (!input.autoCreateCollection) { + // Validate collection exists and user has access + const collectionValidation = await this.validateCollection(user, input); + if (E.isLeft(collectionValidation)) { + return E.left(collectionValidation.left); + } + } + + // Auto-create collection if needed + if (input.autoCreateCollection) { + const newCollection = await this.createAutoCollection(user, input); + if (E.isLeft(newCollection)) { + return E.left(newCollection.left); + } + collectionID = newCollection.right.id; } // Create mock server @@ -318,7 +414,7 @@ export class MockServerService { name: input.name, subdomain, creatorUid: user.uid, - collectionID: input.collectionID, + collectionID: input.collectionID ?? collectionID, workspaceType: input.workspaceType, workspaceID: input.workspaceType === WorkspaceType.TEAM @@ -335,9 +431,21 @@ export class MockServerService { return E.right(this.cast(mockServer)); } catch (error) { + if (input.autoCreateCollection && collectionID) { + if (input.workspaceType === WorkspaceType.USER) { + await this.userCollectionService.deleteUserCollection( + collectionID, + user.uid, + ); + } else if (input.workspaceType === WorkspaceType.TEAM) { + await this.teamCollectionService.deleteCollection(collectionID); + } + } + if (error.code === PrismaError.UNIQUE_CONSTRAINT_VIOLATION) { return this.createMockServer(user, input); // Retry on subdomain conflict } + console.error('Error creating mock server:', error); return E.left(MOCK_SERVER_CREATION_FAILED); } diff --git a/packages/hoppscotch-backend/src/published-docs/input-type.args.ts b/packages/hoppscotch-backend/src/published-docs/input-type.args.ts index b0014ef4..29f22cf8 100644 --- a/packages/hoppscotch-backend/src/published-docs/input-type.args.ts +++ b/packages/hoppscotch-backend/src/published-docs/input-type.args.ts @@ -1,4 +1,5 @@ import { InputType, Field } from '@nestjs/graphql'; +import { IsOptional, Matches } from 'class-validator'; import { WorkspaceType } from 'src/types/WorkspaceTypes'; @InputType() @@ -13,6 +14,10 @@ export class CreatePublishedDocsArgs { name: 'version', description: 'Version of the published document', }) + @Matches(/^[a-zA-Z0-9.-]+$/, { + message: + 'Version must only contain alphanumeric characters, dots, and hyphens', + }) version: string; @Field({ @@ -62,6 +67,11 @@ export class UpdatePublishedDocsArgs { description: 'Version of the published document', nullable: true, }) + @IsOptional() + @Matches(/^[a-zA-Z0-9.-]+$/, { + message: + 'Version must only contain alphanumeric characters, dots, and hyphens', + }) version?: string; @Field({ 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 3b42c7d4..25e89dca 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.resolver.ts @@ -231,7 +231,7 @@ export class TeamCollectionResolver { parentCollectionID ?? null, ); if (E.isLeft(importedCollection)) throwErr(importedCollection.left); - return importedCollection.right; + return true; } @Mutation(() => TeamCollection, { 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 e2691018..fa2fd22f 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 @@ -1397,7 +1397,7 @@ describe('importCollectionsFromJSON', () => { rootTeamCollection.teamID, null, ); - expect(result).toEqualRight(true); + expect(result).toEqualRight([rootTeamCollection]); }); test('should successfully create new TeamCollections in a child collection and TeamRequests with valid inputs', async () => { @@ -1410,7 +1410,7 @@ describe('importCollectionsFromJSON', () => { rootTeamCollection.teamID, rootTeamCollection.id, ); - expect(result).toEqualRight(true); + expect(result).toEqualRight([rootTeamCollection]); }); test('should send pubsub message to "team_coll//coll_added" on successful creation from jsonString', async () => { 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 be177330..e7044258 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -266,7 +266,7 @@ export class TeamCollectionService { ), ); - return E.right(true); + return E.right(teamCollections); } /** diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index a692d510..78448af1 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -1083,6 +1083,8 @@ "environment_variable_added": "Mock URL added to environment", "environment_variable_updated": "Mock URL updated in environment", "environment_created_with_variable": "Environment created with mock URL", + "add_example_request": "Add example request", + "add_example_request_hint": "The collection will be created with a sample request that demonstrates how to use the mock server", "create_example_collection": "Create example collection", "create_example_collection_hint": "Create a pet store example collection with sample requests (GET, POST, PUT, DELETE)", "creating_example_collection": "Creating example collection...", diff --git a/packages/hoppscotch-common/src/components/mockServer/CreateNewMockServerModal.vue b/packages/hoppscotch-common/src/components/mockServer/CreateNewMockServerModal.vue index f7d1c725..371c6d97 100644 --- a/packages/hoppscotch-common/src/components/mockServer/CreateNewMockServerModal.vue +++ b/packages/hoppscotch-common/src/components/mockServer/CreateNewMockServerModal.vue @@ -186,24 +186,24 @@ - +
- {{ t("mock_server.create_example_collection") }} + {{ t("mock_server.add_example_request") }}
- {{ t("mock_server.create_example_collection_hint") }} + {{ t("mock_server.add_example_request_hint") }}
@@ -276,9 +276,7 @@ :loading="loading" :disabled=" !mockServerName.trim() || - (!effectiveCollectionID && - collectionSelectionMode === 'existing') || - (collectionSelectionMode === 'new' && !createExampleCollection) + (!effectiveCollectionID && collectionSelectionMode === 'existing') " :icon="IconServer" @click="handleCreateMockServer" @@ -300,17 +298,10 @@ import { useReadonlyStream } from "@composables/stream" import { useToast } from "@composables/toast" import { computed, ref, watch } from "vue" import { TippyComponent } from "vue-tippy" -import * as E from "fp-ts/Either" import { MockServer } from "~/helpers/backend/graphql" import { showCreateMockServerModal$ } from "~/newstore/mockServers" import { useMockServer } from "~/composables/useMockServer" import MockServerCreatedInfo from "~/components/mockServer/MockServerCreatedInfo.vue" -import { useService } from "dioc/vue" -import { WorkspaceService } from "~/services/workspace.service" -import { - createMockCollectionForTeam, - createMockCollectionForPersonal, -} from "~/helpers/mockServer/exampleMockCollection" // Icons import IconCheck from "~icons/lucide/check" @@ -330,12 +321,6 @@ const { toggleMockServer, } = useMockServer() -// Services -const workspaceService = useService(WorkspaceService) - -// Current workspace -const currentWorkspace = computed(() => workspaceService.currentWorkspace.value) - // Modal state const modalData = useReadonlyStream(showCreateMockServerModal$, { show: false, @@ -350,7 +335,7 @@ const createdServer = ref(null) const delayInMsVal = ref("0") const isPublic = ref(true) const setInEnvironment = ref(true) -const createExampleCollection = ref(false) +const autoCreateRequestExample = ref(true) const selectedCollectionID = ref("") const selectedCollectionName = ref("") const tippyActions = ref(null) @@ -388,6 +373,8 @@ const effectiveCollectionID = computed(() => { // Get collection name const collectionName = computed(() => { if (selectedCollectionName.value) return selectedCollectionName.value + // When creating new collection, use the mock server name as collection name + if (collectionSelectionMode.value === "new") return mockServerName.value return "Unknown Collection" }) @@ -402,42 +389,6 @@ const selectCollection = (option: any) => { selectedCollectionName.value = option.label } -// Function to create an example collection and return its ID and name -const createExampleCollectionAndGetID = async ( - collectionName: string -): Promise<{ - id: string - name: string -}> => { - const workspaceType = currentWorkspace.value.type - - if (workspaceType === "personal") { - // For personal workspace - const result = await createMockCollectionForPersonal(collectionName) - - if (E.isLeft(result)) { - throw new Error(result.left) - } - - return result.right - } else if (workspaceType === "team" && currentWorkspace.value.teamID) { - // For team workspace - const teamID = currentWorkspace.value.teamID - const result = await createMockCollectionForTeam(teamID, collectionName) - - if (E.isLeft(result)) { - throw new Error(result.left) - } - - // Wait a bit for the subscription to update - await new Promise((resolve) => setTimeout(resolve, 500)) - - return result.right - } - - throw new Error("Unknown workspace type") -} - // Create new mock server const handleCreateMockServer = async () => { // Validate mock server name first @@ -446,53 +397,28 @@ const handleCreateMockServer = async () => { return } - // Start loading and show creating message - loading.value = true - - // If "new collection" mode is selected, create example collection (if toggle is enabled) - let collectionIDToUse = effectiveCollectionID.value - - if (collectionSelectionMode.value === "new") { - if (createExampleCollection.value) { - try { - // Silently create the collection in the background - const newCollection = await createExampleCollectionAndGetID( - mockServerName.value.trim() - ) - - // Update the selected collection with the actual created collection's ID and name - collectionIDToUse = newCollection.id - selectedCollectionID.value = newCollection.id - selectedCollectionName.value = newCollection.name - } catch (error) { - console.error("Failed to create collection:", error) - // If collection creation fails, stop the entire process - toast.error(t("mock_server.failed_to_create_mock_server")) - loading.value = false - return - } - } else { - // If new collection mode but example collection is not enabled - toast.error(t("mock_server.enable_example_collection_hint")) - loading.value = false - return - } - } - - // Validate collection ID - if (!collectionIDToUse) { + // For existing collection mode, validate that a collection is selected + if ( + collectionSelectionMode.value === "existing" && + !effectiveCollectionID.value + ) { toast.error(t("mock_server.select_collection_error")) - loading.value = false return } - // Wait a bit more to ensure collection is fully available in the system - await new Promise((resolve) => setTimeout(resolve, 300)) + // Start loading + loading.value = true + + // Determine if we should auto-create a collection + const isNewCollectionMode = collectionSelectionMode.value === "new" // Now create the mock server const result = await createMockServer({ mockServerName: mockServerName.value, - collectionID: collectionIDToUse, + collectionID: isNewCollectionMode ? undefined : effectiveCollectionID.value, + autoCreateCollection: isNewCollectionMode ? true : undefined, + autoCreateRequestExample: + isNewCollectionMode && autoCreateRequestExample.value ? true : undefined, delayInMs: Number(delayInMsVal.value) || 0, isPublic: isPublic.value, setInEnvironment: setInEnvironment.value, @@ -503,6 +429,12 @@ const handleCreateMockServer = async () => { if (result.success && result.server) { createdServer.value = result.server + + // Update the selected collection info from the created server + if (result.server.collection) { + selectedCollectionID.value = result.server.collection.id + selectedCollectionName.value = result.server.collection.title + } } } @@ -539,19 +471,12 @@ watch(show, (newShow) => { loading.value = false delayInMsVal.value = "0" isPublic.value = true + autoCreateRequestExample.value = true setInEnvironment.value = true - createExampleCollection.value = false selectedCollectionID.value = "" selectedCollectionName.value = "" createdServer.value = null collectionSelectionMode.value = "existing" } }) - -// Auto-enable example collection toggle when switching to "new" mode -watch(collectionSelectionMode, (newMode) => { - if (newMode === "new") { - createExampleCollection.value = true - } -}) diff --git a/packages/hoppscotch-common/src/composables/useMockServer.ts b/packages/hoppscotch-common/src/composables/useMockServer.ts index 8159250c..6cc4ee43 100644 --- a/packages/hoppscotch-common/src/composables/useMockServer.ts +++ b/packages/hoppscotch-common/src/composables/useMockServer.ts @@ -27,9 +27,11 @@ import { addMockServer, mockServers$, updateMockServer as updateMockServerInStore, + loadMockServers, } from "~/newstore/mockServers" import { TeamCollectionsService } from "~/services/team-collection.service" import { WorkspaceService } from "~/services/workspace.service" +import { platform } from "~/platform" export function useMockServer() { const t = useI18n() @@ -62,6 +64,30 @@ export function useMockServer() { : undefined ) + // Function to refetch collections and mock servers + const refetchData = async () => { + try { + // Refetch mock servers + await loadMockServers() + + // Refetch collections based on workspace type + if ( + currentWorkspace.value.type === "team" && + currentWorkspace.value.teamID + ) { + // For team workspace, reload team collections by re-initializing with the same team ID + teamCollectionsService.changeTeamID(currentWorkspace.value.teamID) + } else { + // For personal workspace, load REST collections only (mock servers are REST-based) + if (platform.sync.collections.loadUserCollections) { + await platform.sync.collections.loadUserCollections("REST") + } + } + } catch (error) { + console.error("Failed to refetch data:", error) + } + } + // Function to add mock URL to environment const addMockUrlToEnvironment = async ( mockUrl: string, @@ -190,7 +216,9 @@ export function useMockServer() { // Create new mock server const createMockServer = async (params: { mockServerName: string - collectionID: string + collectionID?: string + autoCreateCollection?: boolean + autoCreateRequestExample?: boolean delayInMs: number isPublic: boolean setInEnvironment: boolean @@ -199,16 +227,24 @@ export function useMockServer() { const { mockServerName, collectionID, + autoCreateCollection, + autoCreateRequestExample, delayInMs, isPublic, setInEnvironment, collectionName, } = params - if (!mockServerName.trim() || !collectionID) { - if (!collectionID) { - toast.error(t("mock_server.select_collection_error")) - } + if (!mockServerName.trim()) { + return { success: false, server: null } + } + + // Exactly one of collectionID or autoCreateCollection must be provided (XOR) + if ( + (!collectionID && !autoCreateCollection) || + (collectionID && autoCreateCollection) + ) { + toast.error(t("mock_server.select_collection_error")) return { success: false, server: null } } @@ -225,11 +261,13 @@ export function useMockServer() { const result = await pipe( createMockServerMutation( mockServerName.trim(), - collectionID, workspaceType, workspaceID, delayInMs, - isPublic + isPublic, + collectionID, + autoCreateCollection, + autoCreateRequestExample ), TE.match( (error) => { @@ -258,6 +296,9 @@ export function useMockServer() { } } + // Refetch collections and mock servers to get the latest data + await refetchData() + return { success: true, server: result } } diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts index 22656cb5..41120439 100644 --- a/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/MockServer.ts @@ -54,11 +54,13 @@ type DeleteMockServerError = export const createMockServer = ( name: string, - collectionID: string, workspaceType: WorkspaceType = WorkspaceType.User, workspaceID?: string, delayInMs: number = 0, - isPublic: boolean = true + isPublic: boolean = true, + collectionID?: string, + autoCreateCollection?: boolean, + autoCreateRequestExample?: boolean ) => TE.tryCatch( async () => { @@ -67,6 +69,8 @@ export const createMockServer = ( input: { name, collectionID, + autoCreateCollection, + autoCreateRequestExample, workspaceType, workspaceID, delayInMs, @@ -107,7 +111,7 @@ export const createMockServer = ( return { ...data, userUid: data.creator?.uid || "", // Legacy field - collectionID: data.collection?.id || collectionID, // Legacy field + collectionID: data.collection?.id || collectionID || "", // Legacy field - use response collection ID if available } as MockServer }, (error) => (error as Error).message as CreateMockServerError diff --git a/packages/hoppscotch-common/src/platform/collections.ts b/packages/hoppscotch-common/src/platform/collections.ts index 4786a792..118c40ae 100644 --- a/packages/hoppscotch-common/src/platform/collections.ts +++ b/packages/hoppscotch-common/src/platform/collections.ts @@ -4,6 +4,7 @@ import * as E from "fp-ts/Either" export type CollectionsPlatformDef = { initCollectionsSync: () => void + loadUserCollections?: (collectionType: "REST" | "GQL") => Promise importToPersonalWorkspace?: ( collections: HoppCollection[], reqType: ReqType diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts index 4f74fc4a..88ced221 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts @@ -1032,6 +1032,7 @@ import { importToPersonalWorkspace } from "./import" export const def: CollectionsPlatformDef = { initCollectionsSync, + loadUserCollections, importToPersonalWorkspace, } diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts index af53ef8b..a018b795 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts @@ -1032,6 +1032,7 @@ function setupUserRequestDeletedSubscription() { export const def: CollectionsPlatformDef = { initCollectionsSync, + loadUserCollections, importToPersonalWorkspace, }