diff --git a/packages/hoppscotch-backend/prisma/migrations/20251110141554_api_doc/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20251110141554_api_doc/migration.sql new file mode 100644 index 00000000..2c8cc010 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20251110141554_api_doc/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "PublishedDocs" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "collectionID" TEXT NOT NULL, + "creatorUid" TEXT NOT NULL, + "version" TEXT NOT NULL, + "autoSync" BOOLEAN NOT NULL, + "documentTree" JSONB, + "workspaceType" "WorkspaceType" NOT NULL, + "workspaceID" TEXT NOT NULL, + "metadata" JSONB, + "createdOn" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedOn" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "PublishedDocs_pkey" PRIMARY KEY ("id") +); diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index f8c4d80a..f77d6323 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -296,6 +296,21 @@ model MockServerActivity { @@index([mockServerID]) } +model PublishedDocs { + id String @id @default(cuid()) + title String + collectionID String + creatorUid String + version String + autoSync Boolean + documentTree Json? // Optional if autoSync is true + workspaceType WorkspaceType + workspaceID String + metadata Json? + createdOn DateTime @default(now()) @db.Timestamptz(3) + updatedOn DateTime @updatedAt @db.Timestamptz(3) +} + enum WorkspaceType { USER TEAM diff --git a/packages/hoppscotch-backend/src/app.module.ts b/packages/hoppscotch-backend/src/app.module.ts index aeaea85b..54ffc82d 100644 --- a/packages/hoppscotch-backend/src/app.module.ts +++ b/packages/hoppscotch-backend/src/app.module.ts @@ -37,6 +37,7 @@ import { PrismaModule } from './prisma/prisma.module'; import { PubSubModule } from './pubsub/pubsub.module'; import { SortModule } from './orchestration/sort/sort.module'; import { MockServerModule } from './mock-server/mock-server.module'; +import { PublishedDocsModule } from './published-docs/published-docs.module'; @Module({ imports: [ @@ -126,6 +127,7 @@ import { MockServerModule } from './mock-server/mock-server.module'; InfraTokenModule, SortModule, MockServerModule, + PublishedDocsModule, ], providers: [ GQLComplexityPlugin, diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index 2f75d323..150f5852 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -928,3 +928,34 @@ export const MOCK_SERVER_LOG_NOT_FOUND = 'mock_server/log_not_found'; */ export const MOCK_SERVER_LOG_DELETION_FAILED = 'mock_server/log_deletion_failed'; + +/** + * Published Docs invalid collection + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_INVALID_COLLECTION = + 'published_docs/invalid_collection'; + +/** + * Published Docs creation failed + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_CREATION_FAILED = 'published_docs/creation_failed'; + +/** + * Published Docs update failed + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_UPDATE_FAILED = 'published_docs/update_failed'; + +/** + * Published Docs deletion failed + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_DELETION_FAILED = 'published_docs/deletion_failed'; + +/** + * Published Docs not found + * (PublishedDocsService) + */ +export const PUBLISHED_DOCS_NOT_FOUND = 'published_docs/not_found'; diff --git a/packages/hoppscotch-backend/src/gql-schema.ts b/packages/hoppscotch-backend/src/gql-schema.ts index 34e613e2..708f7691 100644 --- a/packages/hoppscotch-backend/src/gql-schema.ts +++ b/packages/hoppscotch-backend/src/gql-schema.ts @@ -33,6 +33,7 @@ import { InfraTokenResolver } from './infra-token/infra-token.resolver'; import { SortTeamCollectionResolver } from './orchestration/sort/sort-team-collection.resolver'; import { SortUserCollectionResolver } from './orchestration/sort/sort-user-collection.resolver'; import { MockServerResolver } from './mock-server/mock-server.resolver'; +import { PublishedDocsResolver } from './published-docs/published-docs.resolver'; /** * All the resolvers present in the application. @@ -68,6 +69,7 @@ const RESOLVERS = [ SortUserCollectionResolver, SortTeamCollectionResolver, MockServerResolver, + PublishedDocsResolver, ]; /** 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 1ea8c83d..149c6769 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.resolver.ts @@ -26,14 +26,10 @@ import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; import { TeamAccessRole } from 'src/team/team.model'; import { throwErr } from 'src/utils'; -import { MockServerAnalyticsService } from './mock-server-analytics.service'; @Resolver(() => MockServer) export class MockServerResolver { - constructor( - private readonly mockServerService: MockServerService, - private readonly mockServerAnalyticsService: MockServerAnalyticsService, - ) {} + constructor(private readonly mockServerService: MockServerService) {} // Resolve Fields diff --git a/packages/hoppscotch-backend/src/published-docs/input-type.args.ts b/packages/hoppscotch-backend/src/published-docs/input-type.args.ts new file mode 100644 index 00000000..b0014ef4 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/input-type.args.ts @@ -0,0 +1,81 @@ +import { InputType, Field } from '@nestjs/graphql'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; + +@InputType() +export class CreatePublishedDocsArgs { + @Field({ + name: 'title', + description: 'Title of the published document', + }) + title: string; + + @Field({ + name: 'version', + description: 'Version of the published document', + }) + version: string; + + @Field({ + name: 'autoSync', + description: + 'Whether the published document should auto-sync with the source', + }) + autoSync: boolean; + + @Field(() => WorkspaceType, { + name: 'workspaceType', + description: 'Type of the workspace (e.g., personal, team)', + }) + workspaceType: WorkspaceType; + + @Field({ + name: 'workspaceID', + description: 'ID of the workspace', + }) + workspaceID: string; + + @Field({ + name: 'collectionID', + description: + 'ID of the source (personal/team) collection from which to publish', + }) + collectionID: string; + + @Field({ + name: 'metadata', + description: 'Metadata associated with the published document', + }) + metadata: string; +} + +@InputType() +export class UpdatePublishedDocsArgs { + @Field({ + name: 'title', + description: 'Title of the published document', + nullable: true, + }) + title?: string; + + @Field({ + name: 'version', + description: 'Version of the published document', + nullable: true, + }) + version?: string; + + @Field({ + name: 'autoSync', + description: + 'Whether the published document should auto-sync with the source', + nullable: true, + }) + autoSync?: boolean; + + @Field({ + name: 'metadata', + description: 'Metadata associated with the published document', + nullable: true, + }) + metadata?: string; +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts new file mode 100644 index 00000000..d3e6d529 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.controller.ts @@ -0,0 +1,52 @@ +import { + Controller, + Get, + Param, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { PublishedDocsService } from './published-docs.service'; +import { GetPublishedDocsQueryDto } from './published-docs.dto'; +import * as E from 'fp-ts/Either'; +import { throwHTTPErr } from 'src/utils'; +import { PublishedDocs } from './published-docs.model'; + +@ApiTags('Published Docs') +@Controller({ version: '1', path: 'published-docs' }) +export class PublishedDocsController { + constructor(private readonly publishedDocsService: PublishedDocsService) {} + + @Get(':docId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get published documentation', + description: + 'Returns published collection documentation in API-doc JSON format for unauthenticated users', + }) + @ApiResponse({ + status: 200, + description: 'Successfully retrieved published documentation', + type: () => PublishedDocs, + }) + @ApiResponse({ + status: 404, + description: 'Published documentation not found', + }) + async getPublishedDocs( + @Param('docId') docId: string, + @Query() query: GetPublishedDocsQueryDto, + ) { + const result = await this.publishedDocsService.getPublishedDocByIDPublic( + docId, + query, + ); + + if (E.isLeft(result)) { + throwHTTPErr({ message: result.left, statusCode: HttpStatus.NOT_FOUND }); + } + + return result.right; + } +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.dto.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.dto.ts new file mode 100644 index 00000000..baca5632 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.dto.ts @@ -0,0 +1,19 @@ +import { IsEnum, IsOptional } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export enum TreeLevel { + FULL = 'full', + FIRST_LEVEL = 'first_level', +} + +export class GetPublishedDocsQueryDto { + @ApiPropertyOptional({ + description: 'Specifies whether to return full tree or only first level', + enum: TreeLevel, + default: TreeLevel.FULL, + required: false, + }) + @IsOptional() + @IsEnum(TreeLevel) + tree?: TreeLevel = TreeLevel.FULL; +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts new file mode 100644 index 00000000..97c1c02c --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.model.ts @@ -0,0 +1,118 @@ +import { ObjectType, Field, ID } from '@nestjs/graphql'; +import { ApiProperty } from '@nestjs/swagger'; + +@ObjectType() +export class PublishedDocs { + @Field(() => ID, { + description: 'ID of the published API documentation', + }) + @ApiProperty({ + description: 'ID of the published API documentation', + example: 'doc_12345', + }) + id: string; + + @Field({ description: 'Title of the published API documentation' }) + @ApiProperty({ + description: 'Title of the published API documentation', + example: 'My API Documentation', + }) + title: string; + + @Field({ + description: 'URL where the published API documentation can be accessed', + }) + @ApiProperty({ + description: 'URL where the published API documentation can be accessed', + example: 'https://docs.example.com/api', + }) + url: string; + + @Field({ description: 'Version of the published API documentation' }) + @ApiProperty({ + description: 'Version of the published API documentation', + example: '1.0.0', + }) + version: string; + + @Field({ description: 'Indicates if the documentation is set to auto-sync' }) + @ApiProperty({ + description: 'Indicates if the documentation is set to auto-sync', + example: true, + }) + autoSync: boolean; + + @Field({ + description: 'Document tree structure associated with the documentation', + }) + @ApiProperty({ + description: 'Document tree structure associated with the documentation', + example: + '{"id": "string", "name": "string", "folders": [], "requests": [], "data": "string"}', + }) + documentTree: string; + + @Field({ + description: + 'Type of workspace associated with the published documentation', + }) + @ApiProperty({ + description: + 'Type of workspace associated with the published documentation', + example: 'team', + }) + workspaceType: string; + + @Field({ + description: + 'Workspace ID (of team/user ID) associated with the published documentation', + }) + @ApiProperty({ + description: + 'Workspace ID (of team/user ID) associated with the published documentation', + example: 'workspace_12345', + }) + workspaceID: string; + + @Field({ description: 'Metadata of the documentation' }) + @ApiProperty({ + description: 'Metadata of the documentation', + example: '{"author": "John Doe", "tags": ["api", "rest"]}', + }) + metadata: string; + + @Field({ description: 'Timestamp when the documentation was created' }) + @ApiProperty({ + description: 'Timestamp when the documentation was created', + example: '2024-01-01T00:00:00.000Z', + }) + createdOn: Date; + + @Field({ description: 'Timestamp when the documentation was last updated' }) + @ApiProperty({ + description: 'Timestamp when the documentation was last updated', + example: '2024-01-15T12:30:00.000Z', + }) + updatedOn: Date; +} + +@ObjectType() +export class PublishedDocsCollection { + @Field(() => ID, { + description: 'ID of the collection', + }) + @ApiProperty({ + description: 'ID of the collection', + example: 'collection_12345', + }) + id: string; + + @Field({ + description: 'Title of the collection', + }) + @ApiProperty({ + description: 'Title of the collection', + example: 'My API Collection', + }) + title: string; +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.module.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.module.ts new file mode 100644 index 00000000..78edf37a --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PublishedDocsResolver } from './published-docs.resolver'; +import { PublishedDocsService } from './published-docs.service'; +import { TeamModule } from 'src/team/team.module'; +import { PublishedDocsController } from './published-docs.controller'; +import { UserCollectionModule } from 'src/user-collection/user-collection.module'; +import { TeamCollectionModule } from 'src/team-collection/team-collection.module'; + +@Module({ + imports: [UserCollectionModule, TeamModule, TeamCollectionModule], + controllers: [PublishedDocsController], + providers: [PublishedDocsResolver, PublishedDocsService], +}) +export class PublishedDocsModule {} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts new file mode 100644 index 00000000..1a120fb9 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.resolver.ts @@ -0,0 +1,205 @@ +import { UseGuards } from '@nestjs/common'; +import { + Args, + ID, + Mutation, + Parent, + ResolveField, + Resolver, + Query, +} from '@nestjs/graphql'; +import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; +import { PublishedDocs, PublishedDocsCollection } from './published-docs.model'; +import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; +import { GqlUser } from 'src/decorators/gql-user.decorator'; +import { + CreatePublishedDocsArgs, + UpdatePublishedDocsArgs, +} from './input-type.args'; +import { User } from 'src/user/user.model'; +import { PublishedDocsService } from './published-docs.service'; +import * as E from 'fp-ts/Either'; +import { throwErr } from 'src/utils'; +import { GqlTeamMemberGuard } from 'src/team/guards/gql-team-member.guard'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { TeamAccessRole } from 'src/team/team.model'; + +@UseGuards(GqlThrottlerGuard) +@Resolver(() => PublishedDocs) +export class PublishedDocsResolver { + constructor(private readonly publishedDocsService: PublishedDocsService) {} + + // Resolve Fields + + @ResolveField(() => User, { + description: 'Returns the creator of the published document', + }) + async creator(@Parent() publishedDocs: PublishedDocs): Promise { + const creator = await this.publishedDocsService.getPublishedDocsCreator( + publishedDocs.id, + ); + + if (E.isLeft(creator)) throwErr(creator.left); + return { + ...creator.right, + currentGQLSession: JSON.stringify(creator.right.currentGQLSession), + currentRESTSession: JSON.stringify(creator.right.currentRESTSession), + }; + } + + @ResolveField(() => PublishedDocsCollection, { + description: 'Returns the collection of the published document', + }) + async collection( + @Parent() publishedDocs: PublishedDocs, + ): Promise { + const collection = + await this.publishedDocsService.getPublishedDocsCollection( + publishedDocs.id, + ); + + if (E.isLeft(collection)) throwErr(collection.left); + return collection.right; + } + + // Queries + + @Query(() => PublishedDocs, { + description: 'Get a published document by ID', + }) + @UseGuards(GqlAuthGuard) + async publishedDoc( + @GqlUser() user: User, + @Args({ + name: 'id', + type: () => ID, + description: 'Id of the published document to fetch', + }) + id: string, + ) { + const doc = await this.publishedDocsService.getPublishedDocByID(id, user); + + if (E.isLeft(doc)) throwErr(doc.left); + return doc.right; + } + + @Query(() => [PublishedDocs], { + description: 'Get all published documents of a user', + }) + @UseGuards(GqlAuthGuard) + async userPublishedDocsList( + @GqlUser() user: User, + @Args() args: OffsetPaginationArgs, + ) { + const docs = await this.publishedDocsService.getAllUserPublishedDocs( + user.uid, + args, + ); + return docs; + } + + @Query(() => [PublishedDocs], { + description: 'Get all published documents', + }) + @UseGuards(GqlAuthGuard, GqlTeamMemberGuard) + @RequiresTeamRole( + TeamAccessRole.VIEWER, + TeamAccessRole.EDITOR, + TeamAccessRole.OWNER, + ) + async teamPublishedDocsList( + @Args({ + name: 'teamID', + type: () => ID, + description: 'Id of the team to add to', + }) + teamID: string, + @Args({ + name: 'collectionID', + type: () => ID, + description: 'Id of the collection to add to', + }) + collectionID: string, + @Args() args: OffsetPaginationArgs, + ) { + const docs = await this.publishedDocsService.getAllTeamPublishedDocs( + teamID, + collectionID, + args, + ); + return docs; + } + + // Mutations + + @Mutation(() => PublishedDocs, { + description: 'Create a new published document', + }) + @UseGuards(GqlAuthGuard) + async createPublishedDoc( + @GqlUser() user: User, + @Args({ + name: 'args', + type: () => CreatePublishedDocsArgs, + description: 'Arguments for creating a published document', + }) + args: CreatePublishedDocsArgs, + ) { + const newDoc = await this.publishedDocsService.createPublishedDoc( + args, + user, + ); + + if (E.isLeft(newDoc)) throwErr(newDoc.left); + return newDoc.right; + } + + @Mutation(() => PublishedDocs, { + description: 'Update an existing published document', + }) + @UseGuards(GqlAuthGuard) + async updatePublishedDoc( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the published document to update', + type: () => ID, + }) + id: string, + @Args({ + name: 'args', + type: () => UpdatePublishedDocsArgs, + description: 'Arguments for updating a published document', + }) + args: UpdatePublishedDocsArgs, + ) { + const updatedDoc = await this.publishedDocsService.updatePublishedDoc( + id, + args, + user, + ); + + if (E.isLeft(updatedDoc)) throwErr(updatedDoc.left); + return updatedDoc.right; + } + + @Mutation(() => Boolean, { + description: 'Delete a published document by ID', + }) + @UseGuards(GqlAuthGuard) + async deletePublishedDoc( + @GqlUser() user: User, + @Args({ + name: 'id', + description: 'ID of the published document to delete', + type: () => ID, + }) + id: string, + ) { + const result = await this.publishedDocsService.deletePublishedDoc(id, user); + + if (E.isLeft(result)) throwErr(result.left); + return result.right; + } +} diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts new file mode 100644 index 00000000..6e4447a2 --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.service.spec.ts @@ -0,0 +1,904 @@ +import { PublishedDocs as DBPublishedDocs } from 'src/generated/prisma/client'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import { + PUBLISHED_DOCS_CREATION_FAILED, + PUBLISHED_DOCS_DELETION_FAILED, + PUBLISHED_DOCS_INVALID_COLLECTION, + PUBLISHED_DOCS_NOT_FOUND, + PUBLISHED_DOCS_UPDATE_FAILED, + TEAM_INVALID_ID, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { User } from 'src/user/user.model'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; +import { PublishedDocsService } from './published-docs.service'; +import { PublishedDocs } from './published-docs.model'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { + CreatePublishedDocsArgs, + UpdatePublishedDocsArgs, +} from './input-type.args'; +import { TeamAccessRole } from 'src/team/team.model'; +import { TreeLevel } from './published-docs.dto'; +import { ConfigService } from '@nestjs/config'; +import { right } from 'fp-ts/lib/EitherT'; + +const mockPrisma = mockDeep(); +const mockUserCollectionService = mockDeep(); +const mockTeamCollectionService = mockDeep(); +const mockConfigService = mockDeep(); + +const publishedDocsService = new PublishedDocsService( + mockPrisma, + mockUserCollectionService, + mockTeamCollectionService, + mockConfigService, +); + +const currentTime = new Date(); + +const user: User = { + uid: '123344', + email: 'dwight@dundermifflin.com', + displayName: 'Dwight Schrute', + photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', + isAdmin: false, + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: JSON.stringify({}), + currentRESTSession: JSON.stringify({}), +}; + +const userPublishedDoc: DBPublishedDocs = { + id: 'pub_doc_1', + title: 'User API Documentation', + version: '1.0.0', + autoSync: true, + documentTree: {}, + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + collectionID: 'collection_1', + creatorUid: user.uid, + metadata: {}, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const userPublishedDocCasted: PublishedDocs = { + id: userPublishedDoc.id, + title: userPublishedDoc.title, + version: userPublishedDoc.version, + autoSync: userPublishedDoc.autoSync, + documentTree: JSON.stringify(userPublishedDoc.documentTree), + workspaceType: userPublishedDoc.workspaceType, + workspaceID: userPublishedDoc.workspaceID, + metadata: JSON.stringify(userPublishedDoc.metadata), + createdOn: userPublishedDoc.createdOn, + updatedOn: userPublishedDoc.updatedOn, + url: `${mockConfigService.get('VITE_BASE_URL')}/view/${userPublishedDoc.id}/${userPublishedDoc.version}`, +}; + +const teamPublishedDoc: DBPublishedDocs = { + id: 'pub_doc_2', + title: 'Team API Documentation', + version: '1.0.0', + autoSync: true, + documentTree: {}, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + creatorUid: user.uid, + metadata: {}, + createdOn: currentTime, + updatedOn: currentTime, +}; + +const teamPublishedDocCasted: PublishedDocs = { + id: teamPublishedDoc.id, + title: teamPublishedDoc.title, + version: teamPublishedDoc.version, + autoSync: teamPublishedDoc.autoSync, + documentTree: JSON.stringify(teamPublishedDoc.documentTree), + workspaceType: teamPublishedDoc.workspaceType, + workspaceID: teamPublishedDoc.workspaceID, + metadata: JSON.stringify(teamPublishedDoc.metadata), + createdOn: teamPublishedDoc.createdOn, + updatedOn: teamPublishedDoc.updatedOn, + url: `${mockConfigService.get('VITE_BASE_URL')}/view/${teamPublishedDoc.id}/${teamPublishedDoc.version}`, +}; + +beforeEach(() => { + mockReset(mockPrisma); +}); + +describe('getPublishedDocByID', () => { + test('should return a published document with valid ID and user access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.getPublishedDocByID( + userPublishedDoc.id, + user, + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toMatchObject(userPublishedDocCasted); + } + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocByID( + 'invalid_id', + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when user does not have access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + creatorUid: 'different_user', + }); + + const result = await publishedDocsService.getPublishedDocByID( + userPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should return team published document when user has team access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + + const result = await publishedDocsService.getPublishedDocByID( + teamPublishedDoc.id, + user, + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toMatchObject(teamPublishedDocCasted); + } + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when user does not have team access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocByID( + teamPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); +}); + +describe('getAllUserPublishedDocs', () => { + test('should return a list of user published documents with pagination', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([userPublishedDoc]); + + const result = await publishedDocsService.getAllUserPublishedDocs( + user.uid, + { skip: 0, take: 10 }, + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject(userPublishedDocCasted); + }); + + test('should return an empty array when no documents found', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + + const result = await publishedDocsService.getAllUserPublishedDocs( + user.uid, + { skip: 0, take: 10 }, + ); + expect(result).toEqual([]); + }); + + test('should return paginated results correctly', async () => { + const docs = [userPublishedDoc, { ...userPublishedDoc, id: 'pub_doc_3' }]; + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([docs[0]]); + + const result = await publishedDocsService.getAllUserPublishedDocs( + user.uid, + { skip: 0, take: 1 }, + ); + expect(result).toHaveLength(1); + }); +}); + +describe('getAllTeamPublishedDocs', () => { + test('should return a list of team published documents with pagination', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]); + + const result = await publishedDocsService.getAllTeamPublishedDocs( + 'team_1', + 'team_collection_1', + { skip: 0, take: 10 }, + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject(teamPublishedDocCasted); + }); + + test('should return an empty array when no team documents found', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([]); + + const result = await publishedDocsService.getAllTeamPublishedDocs( + 'team_1', + 'team_collection_1', + { skip: 0, take: 10 }, + ); + expect(result).toEqual([]); + }); + + test('should filter by teamID and collectionID correctly', async () => { + mockPrisma.publishedDocs.findMany.mockResolvedValueOnce([teamPublishedDoc]); + + await publishedDocsService.getAllTeamPublishedDocs( + 'team_1', + 'team_collection_1', + { skip: 0, take: 10 }, + ); + + expect(mockPrisma.publishedDocs.findMany).toHaveBeenCalledWith({ + where: { + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + }, + skip: 0, + take: 10, + orderBy: { + createdOn: 'desc', + }, + }); + }); +}); + +describe('createPublishedDoc', () => { + const createArgs: CreatePublishedDocsArgs = { + title: 'New API Documentation', + version: '1.0.0', + autoSync: true, + workspaceType: WorkspaceType.USER, + workspaceID: user.uid, + collectionID: 'collection_1', + metadata: '{}', + }; + + test('should successfully create a user published document with valid inputs', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.create.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toMatchObject(userPublishedDocCasted); + } + }); + + test('should successfully create a team published document with valid inputs', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'team_collection_1', + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + id: 'team_collection_1', + teamID: 'team_1', + } as any); + mockPrisma.publishedDocs.create.mockResolvedValueOnce(teamPublishedDoc); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right).toMatchObject(teamPublishedDocCasted); + } + }); + + test('should throw TEAM_INVALID_ID when team ID is invalid', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: '', + }; + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + expect(result).toEqualLeft(TEAM_INVALID_ID); + }); + + test('should throw TEAM_INVALID_ID when user does not have team access', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + expect(result).toEqualLeft(TEAM_INVALID_ID); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when user collection is invalid', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when team collection is invalid', async () => { + const teamArgs: CreatePublishedDocsArgs = { + ...createArgs, + workspaceType: WorkspaceType.TEAM, + workspaceID: 'team_1', + collectionID: 'invalid_collection', + }; + + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + teamArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when collection does not belong to user', async () => { + // When Prisma queries with where: { id: 'collection_1', userUid: user.uid } + // and the collection doesn't belong to the user, it returns null + mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); + + test('should throw error when metadata is invalid JSON', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + + const result = await publishedDocsService.createPublishedDoc( + { ...createArgs, metadata: '{invalid' }, + user, + ); + expect(E.isLeft(result)).toBe(true); + }); + + test('should throw PUBLISHED_DOCS_CREATION_FAILED on database error', async () => { + mockPrisma.userCollection.findUnique.mockResolvedValueOnce({ + id: 'collection_1', + userUid: user.uid, + } as any); + mockPrisma.publishedDocs.create.mockRejectedValueOnce( + new Error('Database error'), + ); + + const result = await publishedDocsService.createPublishedDoc( + createArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_CREATION_FAILED); + }); +}); + +describe('updatePublishedDoc', () => { + const updateArgs: UpdatePublishedDocsArgs = { + title: 'Updated API Documentation', + version: '2.0.0', + autoSync: false, + metadata: '{"key": "value"}', + }; + + test('should successfully update a published document with valid inputs', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...userPublishedDoc, + title: updateArgs.title, + version: updateArgs.version, + autoSync: updateArgs.autoSync, + }); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + updateArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.title).toBe(updateArgs.title); + expect(result.right.version).toBe(updateArgs.version); + expect(result.right.autoSync).toBe(updateArgs.autoSync); + } + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.updatePublishedDoc( + 'invalid_id', + updateArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should throw PUBLISHED_DOCS_UPDATE_FAILED when user does not have access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + creatorUid: 'different_user', + }); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + updateArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED); + }); + + test('should throw PUBLISHED_DOCS_UPDATE_FAILED when user is not OWNER or EDITOR of team', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.updatePublishedDoc( + teamPublishedDoc.id, + updateArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED); + }); + + test('should successfully update team published document when user has OWNER role', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...teamPublishedDoc, + title: updateArgs.title, + }); + + const result = await publishedDocsService.updatePublishedDoc( + teamPublishedDoc.id, + updateArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + }); + + test('should successfully update team published document when user has EDITOR role', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...teamPublishedDoc, + title: updateArgs.title, + }); + + const result = await publishedDocsService.updatePublishedDoc( + teamPublishedDoc.id, + updateArgs, + user, + ); + + expect(E.isRight(result)).toBe(true); + }); + + test('should throw error when metadata is invalid JSON', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { ...updateArgs, metadata: '{invalid' }, + user, + ); + expect(E.isLeft(result)).toBe(true); + }); + + test('should update only provided fields', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.update.mockResolvedValueOnce({ + ...userPublishedDoc, + title: 'Only Title Updated', + }); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + { title: 'Only Title Updated' }, + user, + ); + + expect(E.isRight(result)).toBe(true); + if (E.isRight(result)) { + expect(result.right.title).toBe('Only Title Updated'); + } + }); + + test('should throw PUBLISHED_DOCS_UPDATE_FAILED on database error', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.update.mockRejectedValueOnce( + new Error('Database error'), + ); + + const result = await publishedDocsService.updatePublishedDoc( + userPublishedDoc.id, + updateArgs, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_UPDATE_FAILED); + }); +}); + +describe('deletePublishedDoc', () => { + test('should successfully delete a user published document', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.delete.mockResolvedValueOnce(userPublishedDoc); + + const result = await publishedDocsService.deletePublishedDoc( + userPublishedDoc.id, + user, + ); + expect(result).toEqualRight(true); + }); + + test('should successfully delete a team published document when user has OWNER role', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.publishedDocs.delete.mockResolvedValueOnce(teamPublishedDoc); + + const result = await publishedDocsService.deletePublishedDoc( + teamPublishedDoc.id, + user, + ); + expect(result).toEqualRight(true); + }); + + test('should successfully delete a team published document when user has EDITOR role', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + mockPrisma.publishedDocs.delete.mockResolvedValueOnce(teamPublishedDoc); + + const result = await publishedDocsService.deletePublishedDoc( + teamPublishedDoc.id, + user, + ); + expect(result).toEqualRight(true); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.deletePublishedDoc( + 'invalid_id', + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should throw PUBLISHED_DOCS_DELETION_FAILED when user does not have access', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + creatorUid: 'different_user', + }); + + const result = await publishedDocsService.deletePublishedDoc( + userPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED); + }); + + test('should throw PUBLISHED_DOCS_DELETION_FAILED when user is not OWNER or EDITOR of team', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.deletePublishedDoc( + teamPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED); + }); + + test('should throw PUBLISHED_DOCS_DELETION_FAILED on database error', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.publishedDocs.delete.mockRejectedValueOnce( + new Error('Database error'), + ); + + const result = await publishedDocsService.deletePublishedDoc( + userPublishedDoc.id, + user, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_DELETION_FAILED); + }); +}); + +describe('getPublishedDocsCreator', () => { + test('should return the creator of a published document', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.user.findUnique.mockResolvedValueOnce(user as any); + + const result = await publishedDocsService.getPublishedDocsCreator( + userPublishedDoc.id, + ); + expect(result).toEqualRight(user); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = + await publishedDocsService.getPublishedDocsCreator('invalid_id'); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); +}); + +describe('getPublishedDocsCollection', () => { + test('should return user collection for user workspace published document', async () => { + const userCollection = { + id: 'collection_1', + userUid: user.uid, + title: 'My Collection', + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.userCollection.findUnique.mockResolvedValueOnce( + userCollection as any, + ); + + const result = await publishedDocsService.getPublishedDocsCollection( + userPublishedDoc.id, + ); + expect(result).toEqualRight(userCollection); + }); + + test('should return team collection for team workspace published document', async () => { + const teamCollection = { + id: 'team_collection_1', + teamID: 'team_1', + title: 'Team Collection', + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce( + teamCollection as any, + ); + + const result = await publishedDocsService.getPublishedDocsCollection( + teamPublishedDoc.id, + ); + expect(result).toEqualRight(teamCollection); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = + await publishedDocsService.getPublishedDocsCollection('invalid_id'); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when user collection is not found', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(userPublishedDoc); + mockPrisma.userCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocsCollection( + userPublishedDoc.id, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); + + test('should throw PUBLISHED_DOCS_INVALID_COLLECTION when team collection is not found', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(teamPublishedDoc); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocsCollection( + teamPublishedDoc.id, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_INVALID_COLLECTION); + }); +}); + +describe('checkPublishedDocsAccess', () => { + test('should return true for user workspace when user is the creator', async () => { + const result = await publishedDocsService.checkPublishedDocsAccess( + userPublishedDoc, + user.uid, + ); + expect(result).toBe(true); + }); + + test('should return false for user workspace when user is not the creator', async () => { + const result = await publishedDocsService.checkPublishedDocsAccess( + userPublishedDoc, + 'different_user', + ); + expect(result).toBe(false); + }); + + test('should return true for team workspace when user has required role', async () => { + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + + const result = await publishedDocsService.checkPublishedDocsAccess( + teamPublishedDoc, + user.uid, + [TeamAccessRole.OWNER], + ); + expect(result).toBe(true); + }); + + test('should return false for team workspace when user does not have required role', async () => { + mockPrisma.team.findFirst.mockResolvedValueOnce(null); + + const result = await publishedDocsService.checkPublishedDocsAccess( + teamPublishedDoc, + user.uid, + [TeamAccessRole.OWNER], + ); + expect(result).toBe(false); + }); + + test('should check for VIEWER role by default', async () => { + mockPrisma.team.findFirst.mockResolvedValueOnce({ id: 'team_1' } as any); + + const result = await publishedDocsService.checkPublishedDocsAccess( + teamPublishedDoc, + user.uid, + ); + expect(result).toBe(true); + + expect(mockPrisma.team.findFirst).toHaveBeenCalledWith({ + where: { + id: 'team_1', + members: { + some: { + userUid: user.uid, + role: { + in: [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ], + }, + }, + }, + }, + }); + }); +}); + +describe('getPublishedDocByIDPublic', () => { + test('should return collection data when autoSync is enabled for user workspace', async () => { + const collectionData = { + id: 'collection_1', + name: 'Test Collection', + folders: [], + requests: [], + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + autoSync: true, + }); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + + const result = await publishedDocsService.getPublishedDocByIDPublic( + userPublishedDoc.id, + { tree: TreeLevel.FULL }, + ); + + expect(result).toMatchObject( + E.right({ + ...userPublishedDocCasted, + documentTree: JSON.stringify(collectionData), + }), + ); + }); + + test('should return collection data when autoSync is enabled for team workspace', async () => { + const collectionData = { + id: 'team_collection_1', + name: 'Team Test Collection', + folders: [], + requests: [], + }; + + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...teamPublishedDoc, + autoSync: true, + }); + mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce( + E.right(collectionData as any), + ); + + const result = await publishedDocsService.getPublishedDocByIDPublic( + teamPublishedDoc.id, + { tree: TreeLevel.FULL }, + ); + + expect(result).toMatchObject( + E.right({ + ...teamPublishedDocCasted, + documentTree: JSON.stringify(collectionData), + }), + ); + }); + + test('should throw PUBLISHED_DOCS_NOT_FOUND when document ID is invalid', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce(null); + + const result = await publishedDocsService.getPublishedDocByIDPublic( + 'invalid_id', + { tree: TreeLevel.FULL }, + ); + expect(result).toEqualLeft(PUBLISHED_DOCS_NOT_FOUND); + }); + + test('should call exportUserCollectionToJSONObject with correct parameters', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...userPublishedDoc, + autoSync: true, + }); + mockUserCollectionService.exportUserCollectionToJSONObject.mockResolvedValueOnce( + E.right({} as any), + ); + + await publishedDocsService.getPublishedDocByIDPublic(userPublishedDoc.id, { + tree: TreeLevel.FULL, + } as any); + + expect( + mockUserCollectionService.exportUserCollectionToJSONObject, + ).toHaveBeenCalledWith(user.uid, 'collection_1', true); + }); + + test('should call exportCollectionToJSONObject with correct parameters', async () => { + mockPrisma.publishedDocs.findUnique.mockResolvedValueOnce({ + ...teamPublishedDoc, + autoSync: true, + }); + mockTeamCollectionService.exportCollectionToJSONObject.mockResolvedValueOnce( + E.right({} as any), + ); + + await publishedDocsService.getPublishedDocByIDPublic(teamPublishedDoc.id, { + tree: TreeLevel.FULL, + }); + + expect( + mockTeamCollectionService.exportCollectionToJSONObject, + ).toHaveBeenCalledWith('team_1', 'team_collection_1', true); + }); +}); diff --git a/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts b/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts new file mode 100644 index 00000000..0707832a --- /dev/null +++ b/packages/hoppscotch-backend/src/published-docs/published-docs.service.ts @@ -0,0 +1,431 @@ +import { Injectable } from '@nestjs/common'; +import { + CreatePublishedDocsArgs, + UpdatePublishedDocsArgs, +} from './input-type.args'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { PublishedDocs as DbPublishedDocs } from 'src/generated/prisma/client'; +import { TeamAccessRole } from 'src/team/team.model'; +import { User } from 'src/user/user.model'; +import { WorkspaceType } from 'src/types/WorkspaceTypes'; +import { + PUBLISHED_DOCS_CREATION_FAILED, + PUBLISHED_DOCS_DELETION_FAILED, + PUBLISHED_DOCS_INVALID_COLLECTION, + PUBLISHED_DOCS_NOT_FOUND, + PUBLISHED_DOCS_UPDATE_FAILED, + TEAM_INVALID_ID, + USERS_NOT_FOUND, +} from 'src/errors'; +import * as E from 'fp-ts/Either'; +import { PublishedDocs } from './published-docs.model'; +import { OffsetPaginationArgs } from 'src/types/input-types.args'; +import { stringToJson } from 'src/utils'; +import { UserCollectionService } from 'src/user-collection/user-collection.service'; +import { TeamCollectionService } from 'src/team-collection/team-collection.service'; +import { CollectionFolder } from 'src/types/CollectionFolder'; +import { GetPublishedDocsQueryDto, TreeLevel } from './published-docs.dto'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class PublishedDocsService { + constructor( + private readonly prisma: PrismaService, + private readonly userCollectionService: UserCollectionService, + private readonly teamCollectionService: TeamCollectionService, + private readonly configService: ConfigService, + ) {} + + /** + * Cast database PublishedDocs to GraphQL PublishedDocs + */ + private cast(doc: DbPublishedDocs): PublishedDocs { + return { + ...doc, + documentTree: JSON.stringify(doc.documentTree), + metadata: JSON.stringify(doc.metadata), + url: `${this.configService.get('VITE_BASE_URL')}/view/${doc.id}/${doc.version}`, + }; + } + + /** + * Check if user has access to a team with specific roles + */ + private async checkTeamAccess( + teamId: string, + userUid: string, + requiredRoles: TeamAccessRole[], + ): Promise { + const team = await this.prisma.team.findFirst({ + where: { + id: teamId, + members: { + some: { userUid, role: { in: requiredRoles } }, + }, + }, + }); + return team ? true : false; + } + + /** + * Validate workspace access permission and existence + */ + private async validateWorkspace( + user: User, + input: { workspaceType: WorkspaceType; workspaceID: string }, + ) { + if (input.workspaceType === WorkspaceType.TEAM) { + if (!input.workspaceID) return E.left(TEAM_INVALID_ID); + + const hasAccess = await this.checkTeamAccess( + input.workspaceID, + user.uid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + + if (!hasAccess) return E.left(TEAM_INVALID_ID); + } + + return E.right(true); + } + + /** + * Validate collection exists and user has access + */ + private async validateCollection( + user: User, + input: { + workspaceType: WorkspaceType; + workspaceID: string; + collectionID: string; + }, + ) { + if (input.workspaceType === WorkspaceType.TEAM) { + const collection = await this.prisma.teamCollection.findUnique({ + where: { id: input.collectionID, teamID: input.workspaceID }, + }); + return collection + ? E.right(collection) + : E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + } else if (input.workspaceType === WorkspaceType.USER) { + const collection = await this.prisma.userCollection.findUnique({ + where: { id: input.collectionID, userUid: user.uid }, + }); + return collection + ? E.right(collection) + : E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + } + + return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + } + + /** + * Check if user has access to a published docs with specific roles + */ + async checkPublishedDocsAccess( + publishedDocs: DbPublishedDocs, + userUid: string, + requiredRoles: TeamAccessRole[] = [ + TeamAccessRole.OWNER, + TeamAccessRole.EDITOR, + TeamAccessRole.VIEWER, + ], + ): Promise { + if (publishedDocs.workspaceType === WorkspaceType.USER) { + return publishedDocs.creatorUid === userUid; + } else if (publishedDocs.workspaceType === WorkspaceType.TEAM) { + return this.checkTeamAccess( + publishedDocs.workspaceID, + userUid, + requiredRoles, + ); + } + return false; + } + + /** + * (Field resolver) + * Get the creator of a mock server + */ + async getPublishedDocsCreator(id: string) { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + const user = await this.prisma.user.findUnique({ + where: { uid: publishedDocs.creatorUid }, + }); + if (!user) return E.left(USERS_NOT_FOUND); + + return E.right(user); + } + + /** + * (Field resolver) + * Get the collection of a published document + */ + async getPublishedDocsCollection(id: string) { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + if (publishedDocs.workspaceType === WorkspaceType.USER) { + const collection = await this.prisma.userCollection.findUnique({ + where: { id: publishedDocs.collectionID }, + }); + if (!collection) return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + return E.right(collection); + } else if (publishedDocs.workspaceType === WorkspaceType.TEAM) { + const collection = await this.prisma.teamCollection.findUnique({ + where: { id: publishedDocs.collectionID }, + }); + if (!collection) return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + return E.right(collection); + } + + return E.left(PUBLISHED_DOCS_INVALID_COLLECTION); + } + + /** + * Get a published document by ID + */ + async getPublishedDocByID(id: string, user: User) { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + // Check access permissions + const hasAccess = await this.checkPublishedDocsAccess( + publishedDocs, + user.uid, + ); + if (!hasAccess) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + return E.right(this.cast(publishedDocs)); + } + + /** + * Get a published document by ID for public access (unauthenticated) + * @param id - The ID of the published document + * @param query - Query parameters specifying tree level + */ + async getPublishedDocByIDPublic( + id: string, + query: GetPublishedDocsQueryDto, + ): Promise> { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + // if autoSync is enabled, fetch from the collection directly + if (publishedDocs.autoSync) { + const collectionResult = + publishedDocs.workspaceType === WorkspaceType.USER + ? await this.userCollectionService.exportUserCollectionToJSONObject( + publishedDocs.creatorUid, + publishedDocs.collectionID, + query.tree === TreeLevel.FULL, + ) + : await this.teamCollectionService.exportCollectionToJSONObject( + publishedDocs.workspaceID, + publishedDocs.collectionID, + query.tree === TreeLevel.FULL, + ); + + if (E.isLeft(collectionResult)) return E.left(collectionResult.left); + + return E.right( + this.cast({ + ...publishedDocs, + documentTree: JSON.parse(JSON.stringify(collectionResult.right)), + }), + ); + } + + return E.right(this.cast(publishedDocs)); + } + + /** + * Get all published documents for a user with pagination + * @param userUid - The UID of the user + * @param args - Pagination arguments + */ + async getAllUserPublishedDocs(userUid: string, args: OffsetPaginationArgs) { + const docs = await this.prisma.publishedDocs.findMany({ + where: { + workspaceType: WorkspaceType.USER, + creatorUid: userUid, + }, + skip: args.skip, + take: args.take, + orderBy: { + createdOn: 'desc', + }, + }); + + return docs.map((doc) => this.cast(doc)); + } + + /** + * Get all published documents for a team and collection with pagination + */ + async getAllTeamPublishedDocs( + teamID: string, + collectionID: string, + args: OffsetPaginationArgs, + ) { + const docs = await this.prisma.publishedDocs.findMany({ + where: { + workspaceType: WorkspaceType.TEAM, + workspaceID: teamID, + collectionID: collectionID, + }, + skip: args.skip, + take: args.take, + orderBy: { + createdOn: 'desc', + }, + }); + + return docs.map((doc) => this.cast(doc)); + } + + /** + * Create a new published document + * @param args - Arguments for creating the published document + * @param user - The user creating the published document + */ + async createPublishedDoc(args: CreatePublishedDocsArgs, user: User) { + try { + // Validate workspace type and ID + const workspaceValidation = await this.validateWorkspace(user, { + workspaceType: args.workspaceType, + workspaceID: args.workspaceID, + }); + if (E.isLeft(workspaceValidation)) { + return E.left(workspaceValidation.left); + } + + // Validate collection exists and user has access + const collectionValidation = await this.validateCollection(user, { + workspaceType: args.workspaceType, + workspaceID: args.workspaceID, + collectionID: args.collectionID, + }); + if (E.isLeft(collectionValidation)) { + return E.left(collectionValidation.left); + } + + // Parse metadata + const metadata = stringToJson(args.metadata); + if (E.isLeft(metadata)) return E.left(metadata.left); + + // Create published document + const newPublishedDoc = await this.prisma.publishedDocs.create({ + data: { + title: args.title, + collectionID: args.collectionID, + creatorUid: user.uid, + version: args.version, + autoSync: args.autoSync, + workspaceType: args.workspaceType, + workspaceID: + args.workspaceType === WorkspaceType.TEAM + ? args.workspaceID + : user.uid, + metadata: metadata.right, + }, + }); + + return E.right(this.cast(newPublishedDoc)); + } catch (error) { + console.error('Error creating published document:', error); + return E.left(PUBLISHED_DOCS_CREATION_FAILED); + } + } + + /** + * Update an existing published document + * @param id - The ID of the published document to update + * @param args - Arguments for updating the published document + * @param user - The user updating the published document + */ + async updatePublishedDoc( + id: string, + args: UpdatePublishedDocsArgs, + user: User, + ): Promise> { + try { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + // Check access permissions based on workspace type (only OWNER and EDITOR can update) + const hasAccess = await this.checkPublishedDocsAccess( + publishedDocs, + user.uid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + if (!hasAccess) return E.left(PUBLISHED_DOCS_UPDATE_FAILED); + + //Parse metadata if provided + let metadata: E.Either; + if (args.metadata) { + metadata = stringToJson(args.metadata); + if (E.isLeft(metadata)) return E.left(metadata.left); + } + + // Update published document + const updatedPublishedDoc = await this.prisma.publishedDocs.update({ + where: { id }, + data: { + title: args.title, + version: args.version, + autoSync: args.autoSync, + metadata: + metadata && E.isRight(metadata) ? metadata.right : undefined, + }, + }); + + return E.right(this.cast(updatedPublishedDoc)); + } catch (error) { + console.error('Error updating published document:', error); + return E.left(PUBLISHED_DOCS_UPDATE_FAILED); + } + } + + /** Delete a published document + * @param id - The ID of the published document to delete + * @param user - The user deleting the published document + */ + async deletePublishedDoc(id: string, user: User) { + try { + const publishedDocs = await this.prisma.publishedDocs.findUnique({ + where: { id }, + }); + if (!publishedDocs) return E.left(PUBLISHED_DOCS_NOT_FOUND); + + // Check access permissions based on workspace type (only OWNER and EDITOR can update) + const hasAccess = await this.checkPublishedDocsAccess( + publishedDocs, + user.uid, + [TeamAccessRole.OWNER, TeamAccessRole.EDITOR], + ); + if (!hasAccess) return E.left(PUBLISHED_DOCS_DELETION_FAILED); + + await this.prisma.publishedDocs.delete({ + where: { id }, + }); + + return E.right(true); + } catch (error) { + console.error('Error deleting published document:', error); + return E.left(PUBLISHED_DOCS_DELETION_FAILED); + } + } +} 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 059b69a0..be177330 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -106,31 +106,35 @@ export class TeamCollectionService { * * @param teamID The Team ID * @param collectionID The Collection ID + * @param withChildren Whether to include child collections and their requests * @returns A JSON string containing all the contents of a collection */ async exportCollectionToJSONObject( teamID: string, collectionID: string, + withChildren: boolean = true, ): Promise | E.Left> { const collection = await this.getCollection(collectionID); if (E.isLeft(collection)) return E.left(TEAM_INVALID_COLL_ID); - const childrenCollection = await this.prisma.teamCollection.findMany({ - where: { - teamID, - parentID: collectionID, - }, - orderBy: { - orderIndex: 'asc', - }, - }); - const childrenCollectionObjects = []; - for (const coll of childrenCollection) { - const result = await this.exportCollectionToJSONObject(teamID, coll.id); - if (E.isLeft(result)) return E.left(result.left); + if (withChildren) { + const childrenCollection = await this.prisma.teamCollection.findMany({ + where: { + teamID, + parentID: collectionID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); - childrenCollectionObjects.push(result.right); + for (const coll of childrenCollection) { + const result = await this.exportCollectionToJSONObject(teamID, coll.id); + if (E.isLeft(result)) return E.left(result.left); + + childrenCollectionObjects.push(result.right); + } } const requests = await this.prisma.teamRequest.findMany({ @@ -146,9 +150,17 @@ export class TeamCollectionService { const data = transformCollectionData(collection.right.data); const result: CollectionFolder = { + id: collection.right.id, name: collection.right.title, folders: childrenCollectionObjects, - requests: requests.map((x) => x.request), + requests: requests.map((x) => { + const requestData = + typeof x.request === 'string' ? JSON.parse(x.request) : x.request; + return { + ...requestData, + id: x.id, + }; + }), data, }; diff --git a/packages/hoppscotch-backend/src/types/CollectionFolder.ts b/packages/hoppscotch-backend/src/types/CollectionFolder.ts index 990a22b5..e7e25aac 100644 --- a/packages/hoppscotch-backend/src/types/CollectionFolder.ts +++ b/packages/hoppscotch-backend/src/types/CollectionFolder.ts @@ -1,8 +1,34 @@ -// This interface defines how data will be received from the app when we are importing Hoppscotch collections -export interface CollectionFolder { +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +// This class defines how data will be received from the app when we are importing Hoppscotch collections +export class CollectionFolder { + @ApiPropertyOptional({ + description: 'Unique identifier for the collection folder', + example: 'folder_12345', + }) id?: string; + + @ApiProperty({ + description: 'List of subfolders', + type: () => [CollectionFolder], + }) folders: CollectionFolder[]; + + @ApiProperty({ + description: 'List of requests in the collection folder', + type: [Object], + }) requests: any[]; + + @ApiProperty({ + description: 'Name of the collection folder', + example: 'My Collection Folder', + }) name: string; + + @ApiPropertyOptional({ + description: 'Additional data for the collection folder', + type: String, + }) data?: string; } diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts index cf9ed265..076f439d 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts @@ -868,37 +868,41 @@ export class UserCollectionService { * * @param userUID The User UID * @param collectionID The Collection ID + * @param withChildren Whether to include child collections and their requests * @returns A JSON string containing all the contents of a collection */ async exportUserCollectionToJSONObject( userUID: string, collectionID: string, + withChildren: boolean = true, ): Promise | E.Right> { // Get Collection details const collection = await this.getUserCollection(collectionID); if (E.isLeft(collection)) return E.left(collection.left); - // Get all child collections whose parentID === collectionID - const childCollectionList = await this.prisma.userCollection.findMany({ - where: { - parentID: collectionID, - userUid: userUID, - }, - orderBy: { - orderIndex: 'asc', - }, - }); - - // Create a list of child collection and request data ready for export const childrenCollectionObjects: CollectionFolder[] = []; - for (const coll of childCollectionList) { - const result = await this.exportUserCollectionToJSONObject( - userUID, - coll.id, - ); - if (E.isLeft(result)) return E.left(result.left); + if (withChildren) { + // Get all child collections whose parentID === collectionID + const childCollectionList = await this.prisma.userCollection.findMany({ + where: { + parentID: collectionID, + userUid: userUID, + }, + orderBy: { + orderIndex: 'asc', + }, + }); - childrenCollectionObjects.push(result.right); + // Create a list of child collection and request data ready for export + for (const coll of childCollectionList) { + const result = await this.exportUserCollectionToJSONObject( + userUID, + coll.id, + ); + if (E.isLeft(result)) return E.left(result.left); + + childrenCollectionObjects.push(result.right); + } } // Fetch all child requests that belong to collectionID diff --git a/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts b/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts index 3e399e67..cf11cf07 100644 --- a/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts +++ b/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts @@ -79,7 +79,7 @@ export const WORKSPACE_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MOC collectionID: "clx1ldkzs005t10f8rp5u60q7", teamID: "clws3hg58000011o8h07glsb1", title: "RequestA", - request: `{"v":"${RESTReqSchemaVersion}","id":"clpttpdq00003qp16kut6doqv","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestA","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the root collection\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Set at root collection\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Bearer BearerToken\\");\\n});","preRequestScript":"","requestVariables":[],"responses":{}}`, + request: `{"v":"${RESTReqSchemaVersion}","id":"clpttpdq00003qp16kut6doqv","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"RequestA","method":"GET","params":[],"headers":[],"endpoint":"https://echo.hoppscotch.io","testScript":"pw.test(\\"Correctly inherits auth and headers from the root collection\\", ()=> {\\n pw.expect(pw.response.body.headers[\\"x-test-header\\"]).toBe(\\"Set at root collection\\");\\n pw.expect(pw.response.body.headers[\\"authorization\\"]).toBe(\\"Bearer BearerToken\\");\\n});","preRequestScript":"","requestVariables":[],"responses":{},"description":""}`, }, ], }, @@ -143,6 +143,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M }, headers: [], variables: [], + description: null, }, ], requests: [ @@ -190,6 +191,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M secret: false, }, ], + description: null, }, ], requests: [ @@ -221,6 +223,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M }, headers: [], variables: [], + description: null, }, ], requests: [ @@ -245,6 +248,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M preRequestScript: "", requestVariables: [], responses: {}, + description: "", }, ], auth: { @@ -268,6 +272,7 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M secret: false, }, ], + description: null, }, ]; @@ -494,7 +499,7 @@ export const WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MO collectionID: "clx1f86hv000010f8szcfya0t", teamID: "clws3hg58000011o8h07glsb1", title: "root-collection-request", - request: `{"v":"${RESTReqSchemaVersion}","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"root-collection-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value set at the root collection\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[],"responses":{}}`, + request: `{"v":"${RESTReqSchemaVersion}","auth":{"authType":"inherit","authActive":true},"body":{"body":null,"contentType":null},"name":"root-collection-request","method":"GET","params":[],"headers":[],"endpoint":"https://httpbin.org/get","testScript":"// Check status code is 200\\npw.test(\\"Status code is 200\\", ()=> {\\n pw.expect(pw.response.status).toBe(200);\\n});\\n\\npw.test(\\"Successfully inherits authorization/header set at the parent collection level\\", () => {\\n pw.expect(pw.response.body.headers[\\"Authorization\\"]).toBe(\\"Basic dGVzdHVzZXI6dGVzdHBhc3M=\\")\\n \\n pw.expect(pw.response.body.headers[\\"Custom-Header\\"]).toBe(\\"Custom header value set at the root collection\\")\\n pw.expect(pw.response.body.headers[\\"Inherited-Header\\"]).toBe(\\"Inherited header at all levels\\")\\n})","preRequestScript":"","requestVariables":[],"responses":{}, "description": ""}`, }, ], }, @@ -503,17 +508,17 @@ export const WORKSPACE_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_MO export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: HoppCollection[] = [ { - v: 10, + v: CollectionSchemaVersion, id: "clx1f86hv000010f8szcfya0t", name: "Multiple child collections with authorization, headers and variables set at each level", folders: [ { - v: 10, + v: 11, id: "clx1fjgah000110f8a5bs68gd", name: "folder-1", folders: [ { - v: 10, + v: 11, id: "clx1fjwmm000410f8l1gkkr1a", name: "folder-11", folders: [], @@ -561,9 +566,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, { - v: 10, + v: 11, id: "clx1fjyxm000510f8pv90dt43", name: "folder-12", folders: [], @@ -627,9 +633,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, { - v: 10, + v: 11, id: "clx1fk1cv000610f88kc3aupy", name: "folder-13", folders: [], @@ -711,6 +718,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, ], requests: [ @@ -755,14 +763,15 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, { - v: 10, + v: 11, id: "clx1fjk9o000210f8j0573pls", name: "folder-2", folders: [ { - v: 10, + v: 11, id: "clx1fk516000710f87sfpw6bo", name: "folder-21", folders: [], @@ -808,9 +817,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, { - v: 10, + v: 11, id: "clx1fk72t000810f8gfwkpi5y", name: "folder-22", folders: [], @@ -874,9 +884,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, { - v: 10, + v: 11, id: "clx1fk95g000910f8bunhaoo8", name: "folder-23", folders: [], @@ -945,6 +956,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, ], requests: [ @@ -995,15 +1007,16 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, { - v: 10, + v: 11, id: "clx1fjmlq000310f86o4d3w2o", name: "folder-3", folders: [ { - v: 10, + v: 11, id: "clx1iwq0p003e10f8u8zg0p85", name: "folder-31", folders: [], @@ -1049,9 +1062,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, { - v: 10, + v: 11, id: "clx1izut7003m10f894ip59zg", name: "folder-32", folders: [], @@ -1115,9 +1129,10 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, { - v: 10, + v: 11, id: "clx1j2ka9003q10f8cdbzpgpg", name: "folder-33", folders: [], @@ -1186,6 +1201,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, ], requests: [ @@ -1249,6 +1265,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, ], requests: [ @@ -1272,6 +1289,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp preRequestScript: "", requestVariables: [], responses: {}, + description: "", }, ], auth: { @@ -1302,6 +1320,7 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp secret: false, }, ], + description: null, }, ]; @@ -1408,6 +1427,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L }, headers: [], variables: [], + description: null, }, { v: CollectionSchemaVersion, @@ -1455,6 +1475,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L secret: false, }, ], + description: null, }, { v: CollectionSchemaVersion, @@ -1468,6 +1489,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L }, headers: [], variables: [], + description: null, }, { v: CollectionSchemaVersion, @@ -1495,6 +1517,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L secret: false, }, ], + description: null, }, ], requests: [], @@ -1504,6 +1527,7 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L }, headers: [], variables: [], + description: null, }, ]; diff --git a/packages/hoppscotch-cli/src/utils/workspace-access.ts b/packages/hoppscotch-cli/src/utils/workspace-access.ts index d78bd483..6c47ba83 100644 --- a/packages/hoppscotch-cli/src/utils/workspace-access.ts +++ b/packages/hoppscotch-cli/src/utils/workspace-access.ts @@ -178,12 +178,14 @@ export const transformWorkspaceCollections = ( auth?: HoppRESTAuth; headers?: HoppRESTHeaders; variables: HoppCollectionVariable[]; + description: string | null; } = data ? JSON.parse(data) : {}; const { auth = { authType: "inherit", authActive: true }, headers = [], variables = [], + description = null, } = parsedData; const transformedAuth = transformAuth(auth); @@ -208,6 +210,7 @@ export const transformWorkspaceCollections = ( auth: transformedAuth, headers: transformedHeaders, variables: filteredCollectionVariables, + description, }; }); }; diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index bc8be5f8..22a6bf95 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -406,8 +406,149 @@ "variable": "Variable {count}" }, "documentation": { + "add_description": "Add description for this collection here...", + "add_description_placeholder": "Add description here...", + "add_request_description": "Add description for request here...", + "auth": { + "access_key": "Access Key", + "access_token": "Access Token", + "add_to": "Add to", + "akamai_edgegrid": "Akamai EdgeGrid", + "algorithm": "Algorithm", + "api_key": "API Key", + "app_id": "App ID", + "auth_id": "Auth ID", + "auth_key": "Auth Key", + "auth_url": "Auth URL", + "aws_signature": "AWS Signature", + "basic_auth": "Basic Auth", + "bearer_token": "Bearer Token", + "client_id": "Client ID", + "client_nonce": "Client Nonce", + "client_secret": "Client Secret", + "client_token": "Client Token", + "delegation": "Delegation", + "digest_auth": "Digest Auth", + "extra_data": "Extra Data", + "grant_type": "Grant Type", + "hawk_auth": "HAWK Auth", + "headers_to_sign": "Headers to Sign", + "host": "Host", + "include_payload_hash": "Include Payload Hash", + "jwt_auth": "JWT Auth", + "max_body_size": "Max Body Size", + "no_auth": "No authentication", + "nonce": "Nonce", + "oauth_2": "OAuth 2.0", + "opaque": "Opaque", + "password": "Password", + "payload": "Payload", + "qop": "QOP", + "realm": "Realm", + "region": "Region", + "scope": "Scope", + "secret_key": "Secret Key", + "service_name": "Service Name", + "timestamp": "Timestamp", + "title": "Authentication", + "token_url": "Token URL", + "user": "User", + "username": "Username" + }, + "body": { + "content_type": "Content Type", + "no_body": "No body defined", + "title": "Body" + }, + "copied_to_clipboard": "Copied to clipboard!", + "curl": { + "click_to_load": "Click to load cURL command", + "copied": "cURL command copied to clipboard!", + "copy_to_clipboard": "Copy to clipboard", + "generating": "Generating cURL command...", + "load": "Load cURL", + "title": "cURL" + }, + "description": "Description", + "error_rendering_markdown": "Error rendering markdown:", + "fetching_documentation": "Fetching Documentation...", "generate": "Generate documentation", - "generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go." + "generate_message": "Import any Hoppscotch collection to generate API documentation on-the-go.", + "headers": { + "no_headers": "No headers defined", + "title": "Headers" + }, + "hide_all_documentation": "Hide All Documentation", + "inherited_from": "Inherited from {name}", + "inherited_with_type": "Inherited {type} from {name}", + "key": "Key", + "loading": "Loading...", + "loading_collection_data": "Loading Collection Data...", + "no": "No", + "no_collection_data": "No collection data available", + "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", + "not_set": "Not set", + "open_request_in_new_tab": "Open request in new tab", + "parameters": { + "no_params": "No parameters defined", + "title": "Parameters" + }, + "percent_complete": "% complete", + "processing_documentation": "Processing Documentation", + "publish": { + "already_published": "This collection is already published", + "auto_sync": "Auto-sync with collection", + "auto_sync_description": "Automatically update published docs when collection changes", + "button": "Publish", + "copy_url": "Copy URL", + "delete_published_doc": "Are you sure you want to delete the published documentation?", + "delete_success": "Published documentation deleted successfully", + "doc_title": "Title", + "doc_version": "Version", + "edit_published_doc": "Edit Published Doc", + "last_updated": "Last Updated", + "metadata": "Metadata (JSON)", + "publish_error": "Failed to publish documentation", + "publish_success": "Documentation published successfully!", + "published": "Published", + "published_url": "Published URL", + "title": "Publish Documentation", + "update_button": "Update", + "update_error": "Failed to update documentation", + "update_published_docs": "Update Published Docs", + "update_success": "Documentation updated successfully!", + "update_title": "Update Published Documentation", + "url_copied": "URL copied to clipboard!", + "view_published": "View Published Docs", + "view_title": "View Published Documentation" + }, + "request_opened_in_new_tab": "Request opened in new tab!", + "response": { + "body": "Response Body", + "copy": "Copy response", + "example_copied": "Response example copied to clipboard!", + "example_copy_failed": "Failed to copy response example", + "headers": "Headers", + "no_examples": "No response examples available", + "title": "Response Examples" + }, + "save_error": "Error saving documentation", + "save_success": "Documentation saved successfully", + "saved_items_status": "Saved {success} items. Failed to save {failure} items.", + "show_all_documentation": "Show All Documentation", + "source": "Source", + "title": "Documentation", + "unsaved_changes": "You have {count} unsaved changes. Please save before closing.", + "untitled_collection": "Untitled Collection", + "untitled_request": "Untitled Request", + "value": "Value", + "variables": { + "no_vars": "No variables defined", + "title": "Variables" + }, + "yes": "Yes" }, "empty": { "activity_logs": "No activity logs found", @@ -1084,6 +1225,7 @@ "ai_request_naming_style_custom_placeholder": "Enter your custom naming style template...", "experimental_scripting_sandbox": "Experimental scripting sandbox", "enable_experimental_mock_servers": "Enable Mock Servers", + "enable_experimental_documentation": "Enable Documentation", "sync": "Synchronise", "sync_collections": "Collections", "sync_description": "These settings are synced to cloud.", diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json index 8b733a36..1fcb664b 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -63,6 +63,7 @@ "buffer": "6.0.3", "cookie-es": "2.0.0", "dioc": "3.0.2", + "dompurify": "3.3.0", "esprima": "4.0.1", "events": "3.3.0", "fp-ts": "2.16.11", @@ -71,6 +72,8 @@ "graphql-language-service-interface": "2.10.2", "graphql-tag": "2.12.6", "hawk": "9.0.2", + "highlight.js": "11.11.1", + "highlightjs-curl": "1.3.0", "insomnia-importers": "3.6.0", "io-ts": "2.2.22", "js-md5": "0.8.3", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index b7460cbb..e4083cbb 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -50,6 +50,25 @@ declare module 'vue' { CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default'] CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default'] CollectionsCollection: typeof import('./components/collections/Collection.vue')['default'] + CollectionsDocumentation: typeof import('./components/collections/documentation/index.vue')['default'] + CollectionsDocumentationCollectionPreview: typeof import('./components/collections/documentation/CollectionPreview.vue')['default'] + CollectionsDocumentationCollectionStructure: typeof import('./components/collections/documentation/CollectionStructure.vue')['default'] + CollectionsDocumentationFolderItem: typeof import('./components/collections/documentation/FolderItem.vue')['default'] + CollectionsDocumentationLazyDocumentationItem: typeof import('./components/collections/documentation/LazyDocumentationItem.vue')['default'] + CollectionsDocumentationMarkdownEditor: typeof import('./components/collections/documentation/MarkdownEditor.vue')['default'] + CollectionsDocumentationPreview: typeof import('./components/collections/documentation/Preview.vue')['default'] + CollectionsDocumentationPublishDocModal: typeof import('./components/collections/documentation/PublishDocModal.vue')['default'] + CollectionsDocumentationRequestItem: typeof import('./components/collections/documentation/RequestItem.vue')['default'] + 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'] + CollectionsDocumentationSectionsHeaders: typeof import('./components/collections/documentation/sections/Headers.vue')['default'] + CollectionsDocumentationSectionsParameters: typeof import('./components/collections/documentation/sections/Parameters.vue')['default'] + CollectionsDocumentationSectionsPreRequestScript: typeof import('./components/collections/documentation/sections/PreRequestScript.vue')['default'] + CollectionsDocumentationSectionsRequestBody: typeof import('./components/collections/documentation/sections/RequestBody.vue')['default'] + CollectionsDocumentationSectionsResponse: typeof import('./components/collections/documentation/sections/Response.vue')['default'] + CollectionsDocumentationSectionsTestScript: typeof import('./components/collections/documentation/sections/TestScript.vue')['default'] + CollectionsDocumentationSectionsVariables: typeof import('./components/collections/documentation/sections/Variables.vue')['default'] CollectionsEdit: typeof import('./components/collections/Edit.vue')['default'] CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default'] CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default'] @@ -78,6 +97,9 @@ declare module 'vue' { ConsoleValue: typeof import('./components/console/Value.vue')['default'] CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default'] CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default'] + DocumentationContent: typeof import('./components/documentation/Content.vue')['default'] + DocumentationHeader: typeof import('./components/documentation/Header.vue')['default'] + DocumentationSkeleton: typeof import('./components/documentation/Skeleton.vue')['default'] Embeds: typeof import('./components/embeds/index.vue')['default'] EmbedsHeader: typeof import('./components/embeds/Header.vue')['default'] EmbedsRequest: typeof import('./components/embeds/Request.vue')['default'] @@ -133,6 +155,7 @@ declare module 'vue' { HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox'] HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'] HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip'] + HoppSmartIcon: typeof import('@hoppscotch/ui')['HoppSmartIcon'] HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'] HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection'] HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'] @@ -211,20 +234,26 @@ 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'] - IconLucideBrush: typeof import('~icons/lucide/brush')['default'] IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default'] + IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] + IconLucideCode2: typeof import('~icons/lucide/code2')['default'] + IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default'] + IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default'] + IconLucideFolder: typeof import('~icons/lucide/folder')['default'] + IconLucideFolderOpen: typeof import('~icons/lucide/folder-open')['default'] IconLucideGlobe: typeof import('~icons/lucide/globe')['default'] IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] - IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] - IconLucideInfo: typeof import('~icons/lucide/info')['default'] IconLucideLayers: typeof import('~icons/lucide/layers')['default'] + IconLucideLightbulb: typeof import('~icons/lucide/lightbulb')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] + IconLucideLoader: typeof import('~icons/lucide/loader')['default'] + IconLucideLoader2: typeof import('~icons/lucide/loader2')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default'] - IconLucideRss: typeof import('~icons/lucide/rss')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default'] + IconLucideTerminal: typeof import('~icons/lucide/terminal')['default'] IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideVerified: typeof import('~icons/lucide/verified')['default'] diff --git a/packages/hoppscotch-common/src/components/collections/Collection.vue b/packages/hoppscotch-common/src/components/collections/Collection.vue index f3ff9f4f..0628d95a 100644 --- a/packages/hoppscotch-common/src/components/collections/Collection.vue +++ b/packages/hoppscotch-common/src/components/collections/Collection.vue @@ -138,6 +138,7 @@ @keyup.m=" isMockServerVisible && mockServerAction?.$el.click() " + @keyup.i="documentationAction?.$el.click()" @keyup.escape="hide()" > + import { useI18n } from "@composables/i18n" +import { useDocumentationVisibility } from "~/composables/documentationVisibility" import { HoppCollection } from "@hoppscotch/data" import { computed, ref, watch } from "vue" import { TippyComponent } from "vue-tippy" @@ -324,6 +339,7 @@ import IconServer from "~icons/lucide/server" import IconSettings2 from "~icons/lucide/settings-2" import IconTrash2 from "~icons/lucide/trash-2" import IconArrowUpDown from "~icons/lucide/arrow-up-down" +import IconBook from "~icons/lucide/book" import { CurrentSortValuesService } from "~/services/current-sort.service" import { useService } from "dioc/vue" import { useMockServerStatus } from "~/composables/mockServer" @@ -380,6 +396,7 @@ const emit = defineEmits<{ (event: "edit-collection"): void (event: "edit-properties"): void (event: "duplicate-collection"): void + (event: "open-documentation"): void (event: "export-data"): void (event: "remove-collection"): void (event: "create-mock-server"): void @@ -411,6 +428,9 @@ const options = ref(null) const propertiesAction = ref(null) const runCollectionAction = ref(null) const sortAction = ref(null) +const documentationAction = ref(null) + +const { isDocumentationVisible } = useDocumentationVisibility() const dragging = ref(false) const ordering = ref(false) diff --git a/packages/hoppscotch-common/src/components/collections/ImportExport.vue b/packages/hoppscotch-common/src/components/collections/ImportExport.vue index d1ac2ace..68ad9b49 100644 --- a/packages/hoppscotch-common/src/components/collections/ImportExport.vue +++ b/packages/hoppscotch-common/src/components/collections/ImportExport.vue @@ -171,6 +171,7 @@ function translateToTeamCollectionFormat(x: HoppCollection) { auth: x.auth, headers: x.headers, variables: x.variables, + description: x.description, } const obj = { @@ -193,6 +194,7 @@ function translateToPersonalCollectionFormat(x: HoppCollection) { auth: x.auth, headers: x.headers, variables: x.variables, + description: x.description, } const obj = { diff --git a/packages/hoppscotch-common/src/components/collections/MyCollections.vue b/packages/hoppscotch-common/src/components/collections/MyCollections.vue index 2e9ba042..264d70f3 100644 --- a/packages/hoppscotch-common/src/components/collections/MyCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/MyCollections.vue @@ -92,6 +92,14 @@ collectionSyncID: node.data.data.data.id, }) " + @open-documentation=" + node.data.type === 'collections' && + emit('open-documentation', { + pathOrID: node.id, + collectionRefID: node.data.data.data._ref_id, + collection: node.data.data.data, + }) + " @edit-properties=" node.data.type === 'collections' && emit('edit-properties', { @@ -189,6 +197,14 @@ collectionSyncID: node.data.data.data.id, }) " + @open-documentation=" + node.data.type === 'folders' && + emit('open-documentation', { + pathOrID: node.id, + collectionRefID: node.data.data.data._ref_id, + collection: node.data.data.data, + }) + " @edit-properties=" node.data.type === 'folders' && emit('edit-properties', { @@ -279,6 +295,15 @@ request: node.data.data.data, }) " + @open-request-documentation=" + node.data.type === 'requests' && + emit('open-request-documentation', { + folderPath: node.data.data.parentIndex, + requestIndex: pathToIndex(node.id), + requestRefID: node.data.data.data._ref_id, + request: node.data.data.data, + }) + " @duplicate-response=" emit('duplicate-response', { folderPath: node.data.data.parentIndex, @@ -559,6 +584,23 @@ const emit = defineEmits<{ collectionSyncID?: string } ): void + ( + event: "open-documentation", + payload: { + pathOrID: string + collectionRefID: string + collection: HoppCollection + } + ): void + ( + event: "open-request-documentation", + payload: { + folderPath: string + requestIndex: string + requestRefID: string + request: HoppRESTRequest + } + ): void ( event: "edit-properties", payload: { diff --git a/packages/hoppscotch-common/src/components/collections/Request.vue b/packages/hoppscotch-common/src/components/collections/Request.vue index c286f9a6..488f5d59 100644 --- a/packages/hoppscotch-common/src/components/collections/Request.vue +++ b/packages/hoppscotch-common/src/components/collections/Request.vue @@ -71,9 +71,9 @@ -
+
+ (null) const options = ref(null) const duplicate = ref(null) const shareAction = ref(null) +const documentationAction = ref(null) + +const { isDocumentationVisible } = useDocumentationVisibility() const dragging = ref(false) const ordering = ref(false) diff --git a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue index e7ba5712..12c2efe3 100644 --- a/packages/hoppscotch-common/src/components/collections/TeamCollections.vue +++ b/packages/hoppscotch-common/src/components/collections/TeamCollections.vue @@ -118,6 +118,14 @@ collection: node.data.data.data, }) " + @open-documentation=" + node.data.type === 'collections' && + emit('open-documentation', { + pathOrID: node.id, + collectionRefID: node.data.data.data.id, + collection: node.data.data.data, + }) + " @create-mock-server=" node.data.type === 'collections' && emit('create-mock-server', { @@ -226,6 +234,14 @@ collection: node.data.data.data, }) " + @open-documentation=" + node.data.type === 'folders' && + emit('open-documentation', { + pathOrID: node.id, + collectionRefID: node.data.data.data.id, + collection: node.data.data.data, + }) + " @export-data=" node.data.type === 'folders' && emit('export-data', node.data.data.data) @@ -309,6 +325,15 @@ request: node.data.data.data.request, }) " + @open-request-documentation=" + node.data.type === 'requests' && + emit('open-request-documentation', { + folderPath: getPath(node.id), + requestIndex: node.data.data.data.id, + requestRefID: node.data.data.data.id, + request: node.data.data.data.request, + }) + " @edit-response=" emit('edit-response', { folderPath: node.data.data.parentIndex, @@ -657,6 +682,23 @@ const emit = defineEmits<{ } ): void (event: "select-response", payload: ResponsePayload): void + ( + event: "open-documentation", + payload: { + pathOrID: string + collectionRefID: string + collection: TeamCollection + } + ): void + ( + event: "open-request-documentation", + payload: { + folderPath: string + requestIndex: string + requestRefID?: string + request: HoppRESTRequest + } + ): void ( event: "share-request", payload: { diff --git a/packages/hoppscotch-common/src/components/collections/documentation/CollectionPreview.vue b/packages/hoppscotch-common/src/components/collections/documentation/CollectionPreview.vue new file mode 100644 index 00000000..589209f4 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/CollectionPreview.vue @@ -0,0 +1,169 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue b/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue new file mode 100644 index 00000000..e8316e15 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/CollectionStructure.vue @@ -0,0 +1,263 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/FolderItem.vue b/packages/hoppscotch-common/src/components/collections/documentation/FolderItem.vue new file mode 100644 index 00000000..a3b11d85 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/FolderItem.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/LazyDocumentationItem.vue b/packages/hoppscotch-common/src/components/collections/documentation/LazyDocumentationItem.vue new file mode 100644 index 00000000..8d9577e7 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/LazyDocumentationItem.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/MarkdownEditor.vue b/packages/hoppscotch-common/src/components/collections/documentation/MarkdownEditor.vue new file mode 100644 index 00000000..28296add --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/MarkdownEditor.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue b/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue new file mode 100644 index 00000000..07891362 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/Preview.vue @@ -0,0 +1,512 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue new file mode 100644 index 00000000..d4fae392 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/PublishDocModal.vue @@ -0,0 +1,287 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/RequestItem.vue b/packages/hoppscotch-common/src/components/collections/documentation/RequestItem.vue new file mode 100644 index 00000000..5a839e90 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/RequestItem.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/RequestPreview.vue b/packages/hoppscotch-common/src/components/collections/documentation/RequestPreview.vue new file mode 100644 index 00000000..4c7e297b --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/RequestPreview.vue @@ -0,0 +1,532 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/index.vue b/packages/hoppscotch-common/src/components/collections/documentation/index.vue new file mode 100644 index 00000000..f0287e58 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/index.vue @@ -0,0 +1,945 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Auth.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Auth.vue new file mode 100644 index 00000000..78bdbedc --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Auth.vue @@ -0,0 +1,512 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/CurlView.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/CurlView.vue new file mode 100644 index 00000000..bf19c9c2 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/CurlView.vue @@ -0,0 +1,550 @@ + + + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Headers.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Headers.vue new file mode 100644 index 00000000..707fea87 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Headers.vue @@ -0,0 +1,102 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Parameters.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Parameters.vue new file mode 100644 index 00000000..008595f6 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Parameters.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue new file mode 100644 index 00000000..3d5b8c7d --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/RequestBody.vue @@ -0,0 +1,101 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue new file mode 100644 index 00000000..a1290022 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Response.vue @@ -0,0 +1,247 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/documentation/sections/Variables.vue b/packages/hoppscotch-common/src/components/collections/documentation/sections/Variables.vue new file mode 100644 index 00000000..9c25d5b3 --- /dev/null +++ b/packages/hoppscotch-common/src/components/collections/documentation/sections/Variables.vue @@ -0,0 +1,83 @@ + + + diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 89642092..dd78827f 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -53,6 +53,8 @@ displayModalImportExport(true, 'my-collections') " @duplicate-collection="duplicateCollection" + @open-documentation="openDocumentation" + @open-request-documentation="openRequestDocumentation" @duplicate-request="duplicateRequest" @duplicate-response="duplicateResponse" @edit-properties="editProperties" @@ -105,6 +107,8 @@ @edit-request="editRequest" @edit-response="editResponse" @edit-properties="editProperties" + @open-documentation="openDocumentation" + @open-request-documentation="openRequestDocumentation" @create-mock-server="createTeamMockServer" @export-data="exportData" @expand-team-collection="expandTeamCollection" @@ -209,12 +213,33 @@ collectionsType.type === 'team-collections' && hasTeamWriteAccess " :has-team-write-access=" - collectionsType.type === 'team-collections' ? hasTeamWriteAccess : true + hasTeamWriteAccess || collectionsType.type === 'my-collections' " source="REST" @hide-modal="displayModalEditProperties(false)" @set-collection-properties="setCollectionProperties" /> + ({ // Collection Data const editingCollection = ref(null) +const editingCollectionIsTeam = ref(false) const editingCollectionName = ref(null) const editingCollectionIndex = ref(null) const editingCollectionID = ref(null) +const editingCollectionPath = ref(null) + const editingFolder = ref(null) const editingFolderName = ref(null) const editingFolderPath = ref(null) + const editingRequest = ref(null) const editingRequestName = ref("") const editingResponseName = ref("") const editingResponseOldName = ref("") const editingRequestIndex = ref(null) const editingRequestID = ref(null) + const editingResponseID = ref(null) const editingProperties = ref({ @@ -720,6 +751,7 @@ const showModalEditRequest = ref(false) const showModalEditResponse = ref(false) const showModalImportExport = ref(false) const showModalEditProperties = ref(false) +const showModalDocumentation = ref(false) const showConfirmModal = ref(false) const showTeamModalAdd = ref(false) @@ -799,6 +831,12 @@ const displayTeamModalAdd = (show: boolean) => { teamListAdapter.fetchList() } +const displayModalDocumentation = (show: boolean) => { + showModalDocumentation.value = show + + if (!show) resetSelectedData() +} + const addNewRootCollection = async (name: string) => { if (collectionsType.value.type === "my-collections") { modalLoadingState.value = true @@ -818,6 +856,7 @@ const addNewRootCollection = async (name: string) => { authActive: true, }, variables: [], + description: "", }) ) @@ -2917,6 +2956,7 @@ const editProperties = async (payload: { collection: HoppCollection | TeamCollection }) => { const { collection, collectionIndex } = payload + console.log("collection", collection) const collectionId = collection.id ?? collectionIndex.split("/").pop() @@ -2986,6 +3026,7 @@ const editProperties = async (payload: { } as HoppRESTAuth, headers: [] as HoppRESTHeaders, variables: [] as HoppCollectionVariable[], + description: null as string | null, folders: null, requests: null, } @@ -3013,11 +3054,16 @@ const editProperties = async (payload: { }) ) + const collectionData: CollectionDataProps = { + auth: data.auth, + headers: data.headers, + variables: collectionVariables, + description: data.description, + } + coll = { ...coll, - auth: data.auth, - headers: data.headers as HoppRESTHeaders, - variables: collectionVariables as HoppCollectionVariable[], + ...collectionData, } } @@ -3117,9 +3163,13 @@ const setCollectionProperties = (newCollection: { toast.success(t("collection.properties_updated")) } else if (hasTeamWriteAccess.value && collectionId) { const data = { - auth: collection.auth, - headers: collection.headers, - variables: collection.variables, + auth: collection.auth ?? { + authType: "inherit", + authActive: true, + }, + headers: collection.headers ?? [], + variables: collection.variables ?? [], + description: collection.description ?? null, } // Mark as loading BEFORE triggering async update to avoid race conditions and push the collectionId to the loading array @@ -3130,7 +3180,7 @@ const setCollectionProperties = (newCollection: { } pipe( - updateTeamCollection(collectionId, JSON.stringify(data), undefined), + updateTeamCollection(collectionId, data, undefined), TE.match( (err: GQLError) => { toast.error(`${getErrorMessage(err)}`) @@ -3218,6 +3268,62 @@ const sortCollections = (payload: { }) } +const openDocumentation = ({ + pathOrID, + collectionRefID, + collection, +}: { + pathOrID: string + collectionRefID: string + collection: HoppCollection | TeamCollection +}) => { + console.log("Open documentation for", pathOrID, collectionRefID, collection) + editingCollectionPath.value = pathOrID + editingCollection.value = collection + editingCollectionIsTeam.value = + collectionsType.value.type === "team-collections" + editingCollectionID.value = + collectionsType.value.type === "team-collections" + ? (collection.id ?? null) + : ((collection as HoppCollection).id ?? + (collection as HoppCollection)._ref_id ?? + null) + + displayModalDocumentation(true) +} + +const openRequestDocumentation = ({ + folderPath, + requestIndex, + requestRefID, + request, +}: { + folderPath: string + requestIndex: string + requestRefID?: string + request: HoppRESTRequest +}) => { + console.log( + "Open documentation for request", + folderPath, + requestIndex, + requestRefID, + request + ) + // editingCollectionPath.value = pathOrID + // editingCollection.value = collection + + editingRequest.value = request + editingFolderPath.value = folderPath + editingRequestIndex.value = parseInt(requestIndex) + editingRequestID.value = requestIndex + editingCollectionID.value = folderPath.split("/").at(-1) ?? null + editingCollectionIsTeam.value = + collectionsType.value.type === "team-collections" + + displayModalDocumentation(true) +} + const resolveConfirmModal = (title: string | null) => { if (title === `${t("confirm.remove_collection")}`) onRemoveCollection() else if (title === `${t("confirm.remove_request")}`) onRemoveRequest() diff --git a/packages/hoppscotch-common/src/components/documentation/Content.vue b/packages/hoppscotch-common/src/components/documentation/Content.vue new file mode 100644 index 00000000..5476c01b --- /dev/null +++ b/packages/hoppscotch-common/src/components/documentation/Content.vue @@ -0,0 +1,192 @@ + + + diff --git a/packages/hoppscotch-common/src/components/documentation/Header.vue b/packages/hoppscotch-common/src/components/documentation/Header.vue new file mode 100644 index 00000000..e284cbf0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/documentation/Header.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/hoppscotch-common/src/components/documentation/Skeleton.vue b/packages/hoppscotch-common/src/components/documentation/Skeleton.vue new file mode 100644 index 00000000..1b6991e0 --- /dev/null +++ b/packages/hoppscotch-common/src/components/documentation/Skeleton.vue @@ -0,0 +1,94 @@ + diff --git a/packages/hoppscotch-common/src/composables/documentationVisibility.ts b/packages/hoppscotch-common/src/composables/documentationVisibility.ts new file mode 100644 index 00000000..cecebf8b --- /dev/null +++ b/packages/hoppscotch-common/src/composables/documentationVisibility.ts @@ -0,0 +1,23 @@ +import { computed } from "vue" + +import { useSetting } from "~/composables/settings" + +/** + * Composable to determine documentation visibility based on experimental flags + */ +export function useDocumentationVisibility() { + const ENABLE_EXPERIMENTAL_DOCUMENTATION = useSetting( + "ENABLE_EXPERIMENTAL_DOCUMENTATION" + ) + + /** + * Check if documentation should be visible based on experimental flag + */ + const isDocumentationVisible = computed( + () => ENABLE_EXPERIMENTAL_DOCUMENTATION.value + ) + + return { + isDocumentationVisible, + } +} diff --git a/packages/hoppscotch-common/src/composables/useDocumentationWorker.ts b/packages/hoppscotch-common/src/composables/useDocumentationWorker.ts new file mode 100644 index 00000000..211a3e71 --- /dev/null +++ b/packages/hoppscotch-common/src/composables/useDocumentationWorker.ts @@ -0,0 +1,162 @@ +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" +import { ref, readonly } from "vue" + +export interface DocumentationItem { + type: "folder" | "request" + item: HoppCollection | HoppRESTRequest + parentPath: string + id: string + pathOrID?: string | null + folderPath?: string | null + requestIndex?: number | null + requestID?: string | null +} + +interface QueueItem { + collection: HoppCollection + pathOrID: string | null + isTeamCollection: boolean + resolve: (items: DocumentationItem[]) => void + reject: (error: Error) => void +} + +const worker = new Worker( + new URL("../helpers/workers/documentation.worker.ts", import.meta.url), + { + type: "module", + } +) + +// Global queue state +const queue: QueueItem[] = [] +let isWorkerBusy = false + +// Global state refs (shared across composables) +const isProcessing = ref(false) +const progress = ref(0) +const processedCount = ref(0) +const totalCount = ref(0) + +// Worker message handler +worker.onmessage = (event) => { + const { type } = event.data + + switch (type) { + case "DOCUMENTATION_PROGRESS": + progress.value = event.data.progress + processedCount.value = event.data.processed + totalCount.value = event.data.total + break + + case "DOCUMENTATION_RESULT": + if (queue.length > 0) { + const currentItem = queue[0] // The item currently being processed + + // Parse the stringified items + const items = JSON.parse(event.data.items) as DocumentationItem[] + currentItem.resolve(items) + + // Remove completed item and process next + queue.shift() + processQueue() + } + break + + case "DOCUMENTATION_ERROR": + if (queue.length > 0) { + const currentItem = queue[0] + currentItem.reject(new Error(event.data.error)) + + // Remove failed item and process next + queue.shift() + processQueue() + } + break + } +} + +worker.onerror = (error) => { + if (queue.length > 0) { + const currentItem = queue[0] + currentItem.reject(new Error(`Worker error: ${error.message}`)) + + // Remove failed item and process next + queue.shift() + processQueue() + } +} + +function processQueue() { + if (queue.length === 0) { + isWorkerBusy = false + isProcessing.value = false + progress.value = 100 // Ensure progress shows complete + return + } + + isWorkerBusy = true + isProcessing.value = true + progress.value = 0 + processedCount.value = 0 + totalCount.value = 0 + + const nextItem = queue[0] + + try { + const collectionString = JSON.stringify(nextItem.collection) + worker.postMessage({ + type: "GATHER_DOCUMENTATION", + collection: collectionString, + pathOrID: nextItem.pathOrID, + isTeamCollection: nextItem.isTeamCollection, + }) + } catch (error) { + nextItem.reject( + new Error( + `Failed to serialize collection: ${error instanceof Error ? error.message : String(error)}` + ) + ) + queue.shift() + processQueue() + } +} + +export function useDocumentationWorker() { + /** + * Process documentation using the worker + */ + function processDocumentation( + collection: HoppCollection, + pathOrID: string | null, + isTeamCollection: boolean = false + ): Promise { + return new Promise((resolve, reject) => { + if (!collection) { + resolve([]) + return + } + + // Add to queue + queue.push({ + collection, + pathOrID, + isTeamCollection, + resolve, + reject, + }) + + // If worker is not busy, start processing + if (!isWorkerBusy) { + processQueue() + } + }) + } + + return { + isProcessing: readonly(isProcessing), + progress: readonly(progress), + processedCount: readonly(processedCount), + totalCount: readonly(totalCount), + processDocumentation, + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreatePublishedDoc.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreatePublishedDoc.graphql new file mode 100644 index 00000000..afd4ede4 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/CreatePublishedDoc.graphql @@ -0,0 +1,13 @@ +mutation CreatePublishedDoc($args: CreatePublishedDocsArgs!) { + createPublishedDoc(args: $args) { + id + title + version + autoSync + url + createdOn + updatedOn + workspaceType + workspaceID + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeletePublishedDoc.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeletePublishedDoc.graphql new file mode 100644 index 00000000..e515f36a --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/DeletePublishedDoc.graphql @@ -0,0 +1,3 @@ +mutation DeletePublishedDoc($id: ID!) { + deletePublishedDoc(id: $id) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdatePublishedDoc.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdatePublishedDoc.graphql new file mode 100644 index 00000000..974730b7 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/mutations/UpdatePublishedDoc.graphql @@ -0,0 +1,11 @@ +mutation UpdatePublishedDoc($id: ID!, $args: UpdatePublishedDocsArgs!) { + updatePublishedDoc(id: $id, args: $args) { + id + title + version + autoSync + url + createdOn + updatedOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportCollectionToJSON.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportCollectionToJSON.graphql new file mode 100644 index 00000000..8198648c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/ExportCollectionToJSON.graphql @@ -0,0 +1,3 @@ +query ExportCollectionToJSON($teamID: ID!, $collectionID: ID!) { + exportCollectionToJSON(teamID: $teamID, collectionID: $collectionID) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/PublishedDoc.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/PublishedDoc.graphql new file mode 100644 index 00000000..7d360df2 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/PublishedDoc.graphql @@ -0,0 +1,22 @@ +query PublishedDoc($id: ID!) { + publishedDoc(id: $id) { + id + title + version + autoSync + url + metadata + createdOn + updatedOn + creator { + uid + displayName + email + photoURL + } + collection { + id + title + } + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/TeamPublishedDocsList.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/TeamPublishedDocsList.graphql new file mode 100644 index 00000000..480d1efd --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/TeamPublishedDocsList.graphql @@ -0,0 +1,24 @@ +query TeamPublishedDocsList( + $teamID: ID! + $collectionID: ID! + $skip: Int! + $take: Int! +) { + teamPublishedDocsList( + teamID: $teamID + collectionID: $collectionID + skip: $skip + take: $take + ) { + id + title + version + autoSync + url + collection { + id + } + createdOn + updatedOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/gql/queries/UserPublishedDocsList.graphql b/packages/hoppscotch-common/src/helpers/backend/gql/queries/UserPublishedDocsList.graphql new file mode 100644 index 00000000..0f0c80fc --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/gql/queries/UserPublishedDocsList.graphql @@ -0,0 +1,14 @@ +query UserPublishedDocsList($skip: Int!, $take: Int!) { + userPublishedDocsList(skip: $skip, take: $take) { + id + title + version + autoSync + url + collection { + id + } + createdOn + updatedOn + } +} diff --git a/packages/hoppscotch-common/src/helpers/backend/helpers.ts b/packages/hoppscotch-common/src/helpers/backend/helpers.ts index 59a9d54a..d10c1964 100644 --- a/packages/hoppscotch-common/src/helpers/backend/helpers.ts +++ b/packages/hoppscotch-common/src/helpers/backend/helpers.ts @@ -20,22 +20,25 @@ import { TeamRequest } from "../teams/TeamRequest" import { GQLError, runGQLQuery } from "./GQLClient" import { ExportAsJsonDocument, + ExportCollectionToJsonDocument, GetCollectionChildrenIDsDocument, GetCollectionRequestsDocument, GetCollectionTitleAndDataDocument, } from "./graphql" type TeamCollectionJSON = { + id: string name: string folders: TeamCollectionJSON[] requests: HoppRESTRequest[] - data: string + data: string | null } -type CollectionDataProps = { +export type CollectionDataProps = { auth: HoppRESTAuth headers: HoppRESTHeaders variables: HoppCollectionVariable[] + description: string | null } export const BACKEND_PAGE_SIZE = 10 @@ -116,6 +119,7 @@ const parseCollectionData = ( auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } if (!data) { @@ -149,26 +153,36 @@ const parseCollectionData = ( defaultDataProps.variables ) + const description = + typeof parsedData?.description === "string" + ? parsedData.description + : defaultDataProps.description + return { auth, headers, variables, + description, } } // Transforms the collection JSON string obtained with workspace level export to `HoppRESTCollection` -const teamCollectionJSONToHoppRESTColl = ( +export const teamCollectionJSONToHoppRESTColl = ( coll: TeamCollectionJSON ): HoppCollection => { - const { auth, headers, variables } = parseCollectionData(coll.data) + const { auth, headers, variables, description } = parseCollectionData( + coll.data + ) return makeCollection({ + id: coll.id, name: coll.name, - folders: coll.folders.map(teamCollectionJSONToHoppRESTColl), + folders: coll.folders?.map(teamCollectionJSONToHoppRESTColl), requests: coll.requests, auth, headers, variables, + description, }) } @@ -229,9 +243,10 @@ export const teamCollToHoppRESTColl = ( auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } - const { auth, headers, variables } = parseCollectionData(data) + const { auth, headers, variables, description } = parseCollectionData(data) return makeCollection({ id: coll.id, @@ -241,6 +256,7 @@ export const teamCollToHoppRESTColl = ( auth: auth ?? { authType: "inherit", authActive: true }, headers: headers ?? [], variables: variables ?? [], + description: description ?? null, }) } @@ -272,3 +288,36 @@ export const getTeamCollectionJSON = async (teamID: string) => { const hoppCollections = collections.map(teamCollectionJSONToHoppRESTColl) return E.right(JSON.stringify(hoppCollections, null, 2)) } + +/** + * Get the JSON string of a single collection of the specified team + * @param teamID - ID of the team + * @param collectionID - ID of the collection + */ +export const getSingleTeamCollectionJSON = async ( + teamID: string, + collectionID: string +) => { + const data = await runGQLQuery({ + query: ExportCollectionToJsonDocument, + variables: { + teamID, + collectionID, + }, + }) + + if (E.isLeft(data)) { + return E.left(data.left.error.toString()) + } + + const collection = JSON.parse(data.right.exportCollectionToJSON) + + if (!collection) { + const t = getI18n() + + return E.left(t("error.no_collections_to_export")) + } + + const hoppCollection = teamCollectionJSONToHoppRESTColl(collection) + return E.right(JSON.stringify(hoppCollection, null, 2)) +} diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/PublishedDocs.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/PublishedDocs.ts new file mode 100644 index 00000000..e0320473 --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/PublishedDocs.ts @@ -0,0 +1,55 @@ +import { runMutation } from "../GQLClient" +import { + CreatePublishedDocDocument, + CreatePublishedDocMutation, + CreatePublishedDocMutationVariables, + UpdatePublishedDocDocument, + UpdatePublishedDocMutation, + UpdatePublishedDocMutationVariables, + DeletePublishedDocDocument, + DeletePublishedDocMutation, + DeletePublishedDocMutationVariables, + CreatePublishedDocsArgs, + UpdatePublishedDocsArgs, +} from "../graphql" + +type CreatePublishedDocError = + | "published_docs/creation_failed" + | "published_docs/invalid_collection" + | "team/invalid_id" + +type UpdatePublishedDocError = + | "published_docs/update_failed" + | "published_docs/not_found" + +type DeletePublishedDocError = + | "published_docs/deletion_failed" + | "published_docs/not_found" + +export const createPublishedDoc = (doc: CreatePublishedDocsArgs) => + runMutation< + CreatePublishedDocMutation, + CreatePublishedDocMutationVariables, + CreatePublishedDocError + >(CreatePublishedDocDocument, { + args: doc, + }) + +export const updatePublishedDoc = (id: string, doc: UpdatePublishedDocsArgs) => + runMutation< + UpdatePublishedDocMutation, + UpdatePublishedDocMutationVariables, + UpdatePublishedDocError + >(UpdatePublishedDocDocument, { + id, + args: doc, + }) + +export const deletePublishedDoc = (id: string) => + runMutation< + DeletePublishedDocMutation, + DeletePublishedDocMutationVariables, + DeletePublishedDocError + >(DeletePublishedDocDocument, { + id, + }) diff --git a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts index be9a2892..6549f32e 100644 --- a/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts +++ b/packages/hoppscotch-common/src/helpers/backend/mutations/TeamCollection.ts @@ -32,6 +32,7 @@ import { UpdateTeamCollectionMutation, UpdateTeamCollectionMutationVariables, } from "../graphql" +import { CollectionDataProps } from "../helpers" type CreateNewRootCollectionError = "team_coll/short_title" @@ -135,7 +136,7 @@ export const importJSONToTeam = (collectionJSON: string, teamID: string) => export const updateTeamCollection = ( collectionID: string, - data?: string, + data?: CollectionDataProps, newTitle?: string ) => runMutation< @@ -144,7 +145,7 @@ export const updateTeamCollection = ( "" >(UpdateTeamCollectionDocument, { collectionID, - data, + data: JSON.stringify(data), newTitle, }) diff --git a/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts b/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts new file mode 100644 index 00000000..73c726ce --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts @@ -0,0 +1,255 @@ +import * as TE from "fp-ts/TaskEither" +import * as E from "fp-ts/Either" +import { runGQLQuery } from "../GQLClient" +import { + UserPublishedDocsListDocument, + TeamPublishedDocsListDocument, + type UserPublishedDocsListQuery, + type TeamPublishedDocsListQuery, + PublishedDocDocument, + PublishedDocs, +} from "../graphql" +import { + HoppCollection, + makeCollection, + translateToNewRequest, +} from "@hoppscotch/data" +import type { CollectionDataProps } from "../helpers" + +type GetUserPublishedDocsError = "user/not_authenticated" + +type GetTeamPublishedDocsError = "team/not_found" | "team/access_denied" + +// Type for a published doc item returned from list queries +export type PublishedDocListItem = { + id: string + title: string + version: string + autoSync: boolean + url: string + collection: { + id: string + } + createdOn: string + updatedOn: string +} + +// Type for a full published doc returned from single doc query +export type PublishedDoc = PublishedDocListItem & { + metadata?: string + creator?: { + uid: string + displayName: string + email: string + photoURL: string + } + collection: { + id: string + title: string + } +} + +// Type for the GraphQL query response +export type PublishedDocQuery = { + publishedDoc: PublishedDoc +} + +type CollectionFolder = { + id?: string + folders: CollectionFolder[] + // Backend stores this as any, we translate it to HoppRESTRequest via translateToNewRequest + requests: any[] + name: string + data?: string +} + +/** + * Parses the data field (stringified JSON) to extract auth, headers, variables, and description + * @param data The stringified JSON data from CollectionFolder + * @returns Parsed CollectionDataProps with defaults if parsing fails + */ +function parseCollectionDataFromString(data?: string): CollectionDataProps { + const defaultDataProps: CollectionDataProps = { + auth: { authType: "inherit", authActive: true }, + headers: [], + variables: [], + description: null, + } + + if (!data) { + return defaultDataProps + } + + try { + const parsed = JSON.parse(data) as Partial + return { + auth: parsed.auth || defaultDataProps.auth, + headers: parsed.headers || defaultDataProps.headers, + variables: parsed.variables || defaultDataProps.variables, + description: parsed.description || defaultDataProps.description, + } + } catch (error) { + console.error("Failed to parse collection data:", error) + return defaultDataProps + } +} + +/** + * Converts a CollectionFolder (from backend REST API) to HoppCollection format + * @param folder The CollectionFolder to convert + * @returns HoppCollection in the proper format + */ +export function collectionFolderToHoppCollection( + folder: CollectionFolder +): HoppCollection { + // Parse the data field to extract auth, headers, variables, and description + const { auth, headers, variables, description } = + parseCollectionDataFromString(folder.data) + + return makeCollection({ + name: folder.name, + folders: folder.folders.map(collectionFolderToHoppCollection), + requests: (folder.requests || []).map(translateToNewRequest), + auth, + headers, + variables, + description, + id: folder.id, + }) +} + +export const getUserPublishedDocs = (skip: number = 0, take: number = 100) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: UserPublishedDocsListDocument, + variables: { skip, take }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as UserPublishedDocsListQuery + return data.userPublishedDocsList + }, + (error) => error as GetUserPublishedDocsError + ) + +export const getTeamPublishedDocs = ( + teamID: string, + collectionID: string, + skip: number = 0, + take: number = 100 +) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: TeamPublishedDocsListDocument, + variables: { teamID, collectionID, skip, take }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as TeamPublishedDocsListQuery + return data.teamPublishedDocsList + }, + (error) => error as GetTeamPublishedDocsError + ) + +// Helper to find published doc for a specific collection +export const findPublishedDocForCollection = ( + collectionID: string, + isTeam: boolean, + teamID?: string +): TE.TaskEither< + | GetUserPublishedDocsError + | GetTeamPublishedDocsError + | "published_docs/not_found", + PublishedDocListItem +> => { + const query: TE.TaskEither< + GetUserPublishedDocsError | GetTeamPublishedDocsError, + PublishedDocListItem[] + > = ( + isTeam && teamID + ? getTeamPublishedDocs(teamID, collectionID) + : getUserPublishedDocs() + ) as TE.TaskEither< + GetUserPublishedDocsError | GetTeamPublishedDocsError, + PublishedDocListItem[] + > + + return TE.chain( + ( + docs: PublishedDocListItem[] + ): TE.TaskEither< + | GetUserPublishedDocsError + | GetTeamPublishedDocsError + | "published_docs/not_found", + PublishedDocListItem + > => { + const publishedDoc = docs.find( + (doc) => doc.collection.id === collectionID + ) + return publishedDoc + ? TE.right(publishedDoc) + : TE.left("published_docs/not_found" as const) + } + )(query) +} + +type GetPublishedDocError = + | "published_docs/not_found" + | "published_docs/unauthorized" + +// Get a single published doc by ID (GraphQL) +export const getPublishedDocByID = (id: string) => + TE.tryCatch( + async () => { + const result = await runGQLQuery({ + query: PublishedDocDocument, + variables: { id }, + }) + + if (E.isLeft(result)) { + throw result.left + } + + const data = result.right as PublishedDocQuery + return data.publishedDoc + }, + (error) => { + console.error("Error fetching published doc:", error) + return "published_docs/not_found" as GetPublishedDocError + } + ) + +/** + * + * @param id - The ID of the published doc to fetch + * @param tree - The tree level to fetch (FULL or MINIMAL) Default is FULL so we can skip it, keeping it for future use + * @returns The published doc with the specified ID + */ +export const getPublishedDocByIDREST = ( + id: string + //tree: "FULL" | "MINIMAL" = "FULL" +): TE.TaskEither => + TE.tryCatch( + async () => { + const backendUrl = import.meta.env.VITE_BACKEND_API_URL || "" + const response = await fetch(`${backendUrl}/published-docs/${id}`) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return await response.json() + }, + (error) => { + console.error("Error fetching published doc via REST:", error) + return "published_docs/not_found" as GetPublishedDocError + } + ) diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts index 61f47ae8..2646021a 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts @@ -550,12 +550,43 @@ const getHoppScripts = ( return { preRequestScript, testScript } } +const getCollectionDescription = ( + docField?: string | DescriptionDefinition +): string | null => { + if (!docField) { + return null + } + + if (typeof docField === "string") { + return docField + } else if (typeof docField === "object" && "content" in docField) { + return docField.content || null + } + + return null +} + +const getRequestDescription = ( + docField?: string | DescriptionDefinition +): string | null => { + if (!docField) { + return null + } + + if (typeof docField === "string") { + return docField + } else if (typeof docField === "object" && "content" in docField) { + return docField.content || null + } + + return null +} + const getHoppRequest = ( item: Item, importScripts: boolean ): HoppRESTRequest => { const { preRequestScript, testScript } = getHoppScripts(item, importScripts) - return makeRESTRequest({ name: item.name, endpoint: getHoppReqURL(item.request.url), @@ -571,6 +602,7 @@ const getHoppRequest = ( responses: getHoppResponses(item.responses), preRequestScript, testScript, + description: getRequestDescription(item.request.description), }) } @@ -593,6 +625,7 @@ const getHoppFolder = ( auth: getHoppReqAuth(ig.auth), headers: [], variables: getHoppCollVariables(ig), + description: getCollectionDescription(ig.description), }) export const getHoppCollections = ( diff --git a/packages/hoppscotch-common/src/helpers/workers/documentation.worker.ts b/packages/hoppscotch-common/src/helpers/workers/documentation.worker.ts new file mode 100644 index 00000000..6aeef70f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/workers/documentation.worker.ts @@ -0,0 +1,265 @@ +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" +import { DocumentationItem } from "~/composables/useDocumentationWorker" + +interface GatherDocumentationMessage { + type: "GATHER_DOCUMENTATION" + collection: string // JSON stringified collection + pathOrID: string | null + isTeamCollection?: boolean // Flag to indicate team collection +} + +interface DocumentationProgressMessage { + type: "DOCUMENTATION_PROGRESS" + progress: number + processed: number + total: number +} + +interface DocumentationResultMessage { + type: "DOCUMENTATION_RESULT" + items: string // JSON stringified items array +} + +interface DocumentationErrorMessage { + type: "DOCUMENTATION_ERROR" + error: string +} + +type IncomingDocumentationWorkerMessage = GatherDocumentationMessage + +/** + * Gathers all items with documentation from the collection with async processing + */ +async function gatherAllItems( + collection: HoppCollection, + collectionPath: string | null, + isTeamCollection: boolean = false +): Promise { + const items: DocumentationItem[] = [] + let processedCount = 0 + let totalCount = 0 + let lastProgressUpdate = 0 + + if (!collection) { + return [] + } + + // First pass: count total items + const countItems = (coll: HoppCollection): number => { + let count = 0 + if (coll.requests?.length) count += coll.requests.length + if (coll.folders?.length) { + count += coll.folders.length + coll.folders.forEach((folder) => { + count += countItems(folder) + }) + } + return count + } + + totalCount = countItems(collection) + + // Send initial progress + self.postMessage({ + type: "DOCUMENTATION_PROGRESS", + progress: 0, + processed: 0, + total: totalCount, + } satisfies DocumentationProgressMessage) + + const baseCollectionPath = collectionPath || "" + const BATCH_SIZE = 20 // Process items in larger batches + const PROGRESS_UPDATE_THRESHOLD = 10 // Update progress every 10% + + /** + * Update progress with throttling to avoid excessive messages + */ + const updateProgress = async (force = false) => { + const progress = Math.round((processedCount / totalCount) * 100) + + if (force || progress - lastProgressUpdate >= PROGRESS_UPDATE_THRESHOLD) { + self.postMessage({ + type: "DOCUMENTATION_PROGRESS", + progress, + processed: processedCount, + total: totalCount, + } satisfies DocumentationProgressMessage) + + lastProgressUpdate = progress + + // Yield control less frequently for better performance + await new Promise((resolve) => setTimeout(resolve, 0)) + } + } + + /** + * Process folders recursively with optimized batching + */ + const processFoldersAsync = async ( + folders: HoppCollection[], + parentPath: string = "", + currentFolderPath: string = "" + ): Promise => { + for (let folderIndex = 0; folderIndex < folders.length; folderIndex++) { + const folder = folders[folderIndex] + const folderId = + folder.id || + ("_ref_id" in folder ? folder._ref_id : undefined) || + `folder-${folderIndex}` + + let thisFolderPath: string + const pathSegment = isTeamCollection ? folderId : folderIndex.toString() + + if (baseCollectionPath) { + thisFolderPath = currentFolderPath + ? `${baseCollectionPath}/${currentFolderPath}/${pathSegment}` + : `${baseCollectionPath}/${pathSegment}` + } else { + thisFolderPath = currentFolderPath + ? `${currentFolderPath}/${pathSegment}` + : `${pathSegment}` + } + + // Add folder + items.push({ + type: "folder", + item: folder, + parentPath, + id: folderId, + pathOrID: thisFolderPath, + requestIndex: null, + requestID: null, + }) + + processedCount++ + + // Process folder requests in batches + if (folder.requests?.length) { + for (let i = 0; i < folder.requests.length; i += BATCH_SIZE) { + const batchEnd = Math.min(i + BATCH_SIZE, folder.requests.length) + + for (let j = i; j < batchEnd; j++) { + const request = folder.requests[j] + const requestId = + request.id || + ("_ref_id" in request ? request._ref_id : undefined) || + `${folderId}-request-${j}` + + items.push({ + type: "request", + item: request as HoppRESTRequest, + parentPath: parentPath + ? `${parentPath} / ${folder.name}` + : folder.name, + id: requestId, + folderPath: thisFolderPath, + requestIndex: j, + requestID: request.id, + }) + + processedCount++ + } + + await updateProgress() + } + } + + // Process nested folders + if (folder.folders?.length) { + const newParentPath: string = parentPath + ? `${parentPath} / ${folder.name}` + : folder.name + + const relativeFolderPath = currentFolderPath + ? `${currentFolderPath}/${pathSegment}` + : `${pathSegment}` + + await processFoldersAsync( + folder.folders, + newParentPath, + relativeFolderPath + ) + } + + // Update progress less frequently + if (folderIndex % 5 === 0) { + await updateProgress() + } + } + } + + if (collection.folders?.length) { + await processFoldersAsync(collection.folders) + } + + // Process collection requests in larger batches + if (collection.requests?.length) { + for (let i = 0; i < collection.requests.length; i += BATCH_SIZE) { + const batchEnd = Math.min(i + BATCH_SIZE, collection.requests.length) + + for (let j = i; j < batchEnd; j++) { + const request = collection.requests[j] + const requestId = + request.id || + ("_ref_id" in request ? request._ref_id : undefined) || + `request-${j}` + + items.push({ + type: "request", + item: request as HoppRESTRequest, + parentPath: collection?.name || "", + id: requestId, + folderPath: baseCollectionPath, + requestIndex: j, + requestID: request.id, + }) + + processedCount++ + } + + await updateProgress() + } + } + + // Send final progress update + await updateProgress(true) + + return items +} + +self.addEventListener( + "message", + async (event: MessageEvent) => { + const { + type, + collection: collectionString, + pathOrID, + isTeamCollection, + } = event.data + + if (type === "GATHER_DOCUMENTATION") { + try { + // Parse the stringified collection + const collection = JSON.parse(collectionString) as HoppCollection + + const items = await gatherAllItems( + collection, + pathOrID, + isTeamCollection || false + ) + + const result: DocumentationResultMessage = { + type: "DOCUMENTATION_RESULT", + items: JSON.stringify(items), // Stringify the result for cloning + } + self.postMessage(result) + } catch (error) { + const err: DocumentationErrorMessage = { + type: "DOCUMENTATION_ERROR", + error: error instanceof Error ? error.message : String(error), + } + self.postMessage(err) + } + } + } +) diff --git a/packages/hoppscotch-common/src/newstore/collections.ts b/packages/hoppscotch-common/src/newstore/collections.ts index 55e7ce04..c8053c72 100644 --- a/packages/hoppscotch-common/src/newstore/collections.ts +++ b/packages/hoppscotch-common/src/newstore/collections.ts @@ -37,6 +37,7 @@ const defaultRESTCollectionState = { }, headers: [], variables: [], + description: null, }), ], } @@ -53,6 +54,7 @@ const defaultGraphqlCollectionState = { }, headers: [], variables: [], + description: null, }), ], } @@ -362,6 +364,7 @@ const restCollectionDispatchers = defineDispatchers({ }, headers: [], variables: [], + description: null, }) const newState = state @@ -1022,6 +1025,7 @@ const gqlCollectionDispatchers = defineDispatchers({ }, headers: [], variables: [], + description: null, }) const newState = state const indexPaths = path.split("/").map((x) => parseInt(x)) diff --git a/packages/hoppscotch-common/src/newstore/settings.ts b/packages/hoppscotch-common/src/newstore/settings.ts index e4de4cc1..2b4824d0 100644 --- a/packages/hoppscotch-common/src/newstore/settings.ts +++ b/packages/hoppscotch-common/src/newstore/settings.ts @@ -85,6 +85,7 @@ export type SettingsDef = { EXPERIMENTAL_SCRIPTING_SANDBOX: boolean ENABLE_EXPERIMENTAL_MOCK_SERVERS: boolean + ENABLE_EXPERIMENTAL_DOCUMENTATION: boolean } let defaultProxyURL = DEFAULT_HOPP_PROXY_URL @@ -148,6 +149,7 @@ export const getDefaultSettings = (): SettingsDef => { EXPERIMENTAL_SCRIPTING_SANDBOX: true, ENABLE_EXPERIMENTAL_MOCK_SERVERS: true, + ENABLE_EXPERIMENTAL_DOCUMENTATION: true, } } diff --git a/packages/hoppscotch-common/src/pages/settings.vue b/packages/hoppscotch-common/src/pages/settings.vue index 5d63c47a..92fc2efe 100644 --- a/packages/hoppscotch-common/src/pages/settings.vue +++ b/packages/hoppscotch-common/src/pages/settings.vue @@ -156,21 +156,32 @@
-
- - {{ t("settings.experimental_scripting_sandbox") }} - -
-
- - {{ t("settings.enable_experimental_mock_servers") }} - + +
+
+ + {{ t("settings.experimental_scripting_sandbox") }} + +
+
+ + {{ t("settings.enable_experimental_mock_servers") }} + +
+
+ + {{ t("settings.enable_experimental_documentation") }} + +
@@ -365,6 +376,9 @@ const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting( const ENABLE_EXPERIMENTAL_MOCK_SERVERS = useSetting( "ENABLE_EXPERIMENTAL_MOCK_SERVERS" ) +const ENABLE_EXPERIMENTAL_DOCUMENTATION = useSetting( + "ENABLE_EXPERIMENTAL_DOCUMENTATION" +) const supportedNamingStyles = [ { diff --git a/packages/hoppscotch-common/src/pages/view/_id/_version.vue b/packages/hoppscotch-common/src/pages/view/_id/_version.vue new file mode 100644 index 00000000..4d5bb567 --- /dev/null +++ b/packages/hoppscotch-common/src/pages/view/_id/_version.vue @@ -0,0 +1,208 @@ + + + + + +meta: + layout: empty + diff --git a/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts new file mode 100644 index 00000000..4562dccd --- /dev/null +++ b/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts @@ -0,0 +1,454 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { TestContainer } from "dioc/testing" +import { + HoppCollection, + HoppRESTRequest, + makeCollection, + makeRESTRequest, +} from "@hoppscotch/data" +import { + DocumentationService, + CollectionDocumentationItem, + RequestDocumentationItem, + SetCollectionDocumentationOptions, + SetRequestDocumentationOptions, +} from "../documentation.service" + +describe("DocumentationService", () => { + let container: TestContainer + let service: DocumentationService + + // Test data + const mockCollection: HoppCollection = makeCollection({ + name: "Test Collection", + folders: [], + requests: [], + auth: { authType: "none", authActive: true }, + headers: [], + variables: [], + id: "collection-123", + description: null, + }) + + const mockRequest: HoppRESTRequest = makeRESTRequest({ + name: "Test Request", + endpoint: "https://api.example.com/test", + method: "GET", + headers: [], + params: [], + auth: { authType: "inherit", authActive: true }, + preRequestScript: "", + testScript: "", + body: { contentType: null, body: null }, + requestVariables: [], + responses: {}, + description: null, + }) + + const mockCollectionOptions: SetCollectionDocumentationOptions = { + isTeamItem: false, + pathOrID: "test-path", + collectionData: mockCollection, + } + + const mockTeamCollectionOptions: SetCollectionDocumentationOptions = { + isTeamItem: true, + teamID: "team-456", + pathOrID: "team-collection-789", + collectionData: mockCollection, + } + + const mockRequestOptions: SetRequestDocumentationOptions = { + isTeamItem: false, + parentCollectionID: "collection-123", + folderPath: "test-folder", + requestIndex: 0, + requestData: mockRequest, + } + + const mockTeamRequestOptions: SetRequestDocumentationOptions = { + isTeamItem: true, + teamID: "team-456", + parentCollectionID: "collection-123", + folderPath: "team-folder", + requestID: "request-789", + requestData: mockRequest, + } + + beforeEach(() => { + container = new TestContainer() + service = container.bind(DocumentationService) + }) + + describe("Collection Documentation", () => { + it("should set and get collection documentation", () => { + const collectionId = "collection-123" + const documentation = "# Test Collection\nThis is a test collection." + + service.setCollectionDocumentation( + collectionId, + documentation, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe( + documentation + ) + }) + + it("should store complete collection documentation item", () => { + const collectionId = "collection-123" + const documentation = "# Test Collection\nThis is a test collection." + + service.setCollectionDocumentation( + collectionId, + documentation, + mockCollectionOptions + ) + + const item = service.getDocumentationItem( + "collection", + collectionId + ) as CollectionDocumentationItem + + expect(item).toEqual({ + type: "collection", + id: collectionId, + documentation, + isTeamItem: false, + teamID: undefined, + pathOrID: "test-path", + collectionData: mockCollection, + }) + }) + + it("should handle team collection documentation", () => { + const collectionId = "team-collection-789" + const documentation = "# Team Collection\nThis is a team collection." + + service.setCollectionDocumentation( + collectionId, + documentation, + mockTeamCollectionOptions + ) + + const item = service.getDocumentationItem( + "collection", + collectionId + ) as CollectionDocumentationItem + + expect(item.isTeamItem).toBe(true) + expect(item.teamID).toBe("team-456") + }) + + it("should update existing collection documentation", () => { + const collectionId = "collection-123" + const originalDoc = "Original documentation" + const updatedDoc = "Updated documentation" + + service.setCollectionDocumentation( + collectionId, + originalDoc, + mockCollectionOptions + ) + service.setCollectionDocumentation( + collectionId, + updatedDoc, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe( + updatedDoc + ) + }) + }) + + describe("Request Documentation", () => { + it("should set and get request documentation", () => { + const requestId = "request-456" + const documentation = "## Test Request\nThis is a test request." + + service.setRequestDocumentation( + requestId, + documentation, + mockRequestOptions + ) + + expect(service.getDocumentation("request", requestId)).toBe(documentation) + }) + + it("should store complete request documentation item for personal requests", () => { + const requestId = "request-456" + const documentation = "## Test Request\nThis is a test request." + + service.setRequestDocumentation( + requestId, + documentation, + mockRequestOptions + ) + + const item = service.getDocumentationItem( + "request", + requestId + ) as RequestDocumentationItem + + expect(item).toEqual({ + type: "request", + id: requestId, + documentation, + isTeamItem: false, + teamID: undefined, + parentCollectionID: "collection-123", + folderPath: "test-folder", + requestID: undefined, + requestIndex: 0, + requestData: mockRequest, + }) + }) + + it("should store complete request documentation item for team requests", () => { + const requestId = "team-request-789" + const documentation = "## Team Request\nThis is a team request." + + service.setRequestDocumentation( + requestId, + documentation, + mockTeamRequestOptions + ) + + const item = service.getDocumentationItem( + "request", + requestId + ) as RequestDocumentationItem + + expect(item).toEqual({ + type: "request", + id: requestId, + documentation, + isTeamItem: true, + teamID: "team-456", + parentCollectionID: "collection-123", + folderPath: "team-folder", + requestID: "request-789", + requestIndex: undefined, + requestData: mockRequest, + }) + }) + + it("should get parent collection ID for request", () => { + const requestId = "request-456" + const documentation = "## Test Request\nThis is a test request." + + service.setRequestDocumentation( + requestId, + documentation, + mockRequestOptions + ) + + expect(service.getParentCollectionID(requestId)).toBe("collection-123") + }) + + it("should return undefined for parent collection ID when request not found", () => { + expect(service.getParentCollectionID("non-existent")).toBeUndefined() + }) + }) + + describe("Change Tracking", () => { + it("should track if there are changes", () => { + expect(service.hasChanges.value).toBe(false) + + service.setCollectionDocumentation( + "collection-123", + "Test documentation", + mockCollectionOptions + ) + + expect(service.hasChanges.value).toBe(true) + }) + + it("should check if specific item has changes", () => { + const collectionId = "collection-123" + const requestId = "request-456" + + expect(service.hasItemChanges("collection", collectionId)).toBe(false) + expect(service.hasItemChanges("request", requestId)).toBe(false) + + service.setCollectionDocumentation( + collectionId, + "Test documentation", + mockCollectionOptions + ) + + expect(service.hasItemChanges("collection", collectionId)).toBe(true) + expect(service.hasItemChanges("request", requestId)).toBe(false) + }) + + it("should return correct changes count", () => { + expect(service.getChangesCount()).toBe(0) + + service.setCollectionDocumentation( + "collection-123", + "Collection doc", + mockCollectionOptions + ) + + expect(service.getChangesCount()).toBe(1) + + service.setRequestDocumentation( + "request-456", + "Request doc", + mockRequestOptions + ) + + expect(service.getChangesCount()).toBe(2) + }) + + it("should get all changed items", () => { + service.setCollectionDocumentation( + "collection-123", + "Collection doc", + mockCollectionOptions + ) + service.setRequestDocumentation( + "request-456", + "Request doc", + mockRequestOptions + ) + + const changes = service.getChangedItems() + + expect(changes).toHaveLength(2) + expect(changes.some((item) => item.type === "collection")).toBe(true) + expect(changes.some((item) => item.type === "request")).toBe(true) + }) + }) + + describe("Item Management", () => { + beforeEach(() => { + // Set up some test data + service.setCollectionDocumentation( + "collection-123", + "Collection doc", + mockCollectionOptions + ) + service.setRequestDocumentation( + "request-456", + "Request doc", + mockRequestOptions + ) + }) + + it("should remove specific item", () => { + expect(service.hasItemChanges("collection", "collection-123")).toBe(true) + expect(service.getChangesCount()).toBe(2) + + service.removeItem("collection", "collection-123") + + expect(service.hasItemChanges("collection", "collection-123")).toBe(false) + expect(service.getChangesCount()).toBe(1) + }) + + it("should clear all changes", () => { + expect(service.getChangesCount()).toBe(2) + expect(service.hasChanges.value).toBe(true) + + service.clearAll() + + expect(service.getChangesCount()).toBe(0) + expect(service.hasChanges.value).toBe(false) + }) + }) + + describe("Edge Cases", () => { + it("should return undefined for non-existent documentation", () => { + expect( + service.getDocumentation("collection", "non-existent") + ).toBeUndefined() + expect( + service.getDocumentation("request", "non-existent") + ).toBeUndefined() + }) + + it("should return undefined for non-existent documentation item", () => { + expect( + service.getDocumentationItem("collection", "non-existent") + ).toBeUndefined() + expect( + service.getDocumentationItem("request", "non-existent") + ).toBeUndefined() + }) + + it("should handle empty documentation strings", () => { + const collectionId = "collection-empty" + const emptyDoc = "" + + service.setCollectionDocumentation( + collectionId, + emptyDoc, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe( + emptyDoc + ) + }) + + it("should handle documentation with special characters", () => { + const collectionId = "collection-special" + const specialDoc = + "# Test 🚀\n\n**Bold** _italic_ `code`\n\n- List item\n- Another item" + + service.setCollectionDocumentation( + collectionId, + specialDoc, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe( + specialDoc + ) + }) + + it("should handle very long documentation", () => { + const collectionId = "collection-long" + const longDoc = "# Long Documentation\n" + "A".repeat(10000) + + service.setCollectionDocumentation( + collectionId, + longDoc, + mockCollectionOptions + ) + + expect(service.getDocumentation("collection", collectionId)).toBe(longDoc) + }) + + it("should return undefined for parent collection ID when item is not a request", () => { + service.setCollectionDocumentation( + "collection-123", + "Collection doc", + mockCollectionOptions + ) + + // The key will be collection_collection-123, which won't match request_ prefix + expect(service.getParentCollectionID("collection-123")).toBeUndefined() + }) + }) + + describe("Reactive Properties", () => { + it("should reactively update hasChanges computed property", () => { + expect(service.hasChanges.value).toBe(false) + + service.setCollectionDocumentation( + "collection-123", + "Test doc", + mockCollectionOptions + ) + + expect(service.hasChanges.value).toBe(true) + + service.clearAll() + + expect(service.hasChanges.value).toBe(false) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/documentation.service.ts b/packages/hoppscotch-common/src/services/documentation.service.ts new file mode 100644 index 00000000..46ccca12 --- /dev/null +++ b/packages/hoppscotch-common/src/services/documentation.service.ts @@ -0,0 +1,229 @@ +import { Service } from "dioc" +import { reactive, computed } from "vue" +import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data" + +// Types for documentation +export type DocumentationType = "collection" | "request" + +/** + * Base documentation item with common properties + */ +export interface BaseDocumentationItem { + id: string + documentation: string + isTeamItem: boolean + teamID?: string +} + +/** + * Collection documentation item + */ +export interface CollectionDocumentationItem extends BaseDocumentationItem { + type: "collection" + + /** + * The path (for personal collections) or ID (for team collections) of the collection + */ + pathOrID: string + collectionData: HoppCollection +} + +/** + * Request documentation item (supports both team and personal requests) + */ +export interface RequestDocumentationItem extends BaseDocumentationItem { + type: "request" + parentCollectionID: string + folderPath: string + requestID?: string // For team requests + requestIndex?: number // For personal requests + requestData: HoppRESTRequest +} + +export type DocumentationItem = + | CollectionDocumentationItem + | RequestDocumentationItem + +/** + * Base options for setting documentation + */ +export interface BaseDocumentationOptions { + isTeamItem: boolean + teamID?: string +} + +/** + * Options for setting collection documentation + */ +export interface SetCollectionDocumentationOptions + extends BaseDocumentationOptions { + /** + * The path (for personal collections) or ID (for team collections) of the collection + */ + pathOrID: string + collectionData: HoppCollection +} + +/** + * Request documentation + */ +export interface SetRequestDocumentationOptions + extends BaseDocumentationOptions { + parentCollectionID: string + folderPath: string + requestID?: string // For team requests + requestIndex?: number // For personal requests + requestData: HoppRESTRequest +} + +/** + * This service manages edited documentation for collections and requests. + * It temporarily stores the edited documentation in a map for efficient saving. + * So that multiple edits can be batched together. + */ +export class DocumentationService extends Service { + public static readonly ID = "DOCUMENTATION_SERVICE" + + private editedDocumentation = reactive(new Map()) + + /** + * Computed property to check if there are any unsaved changes + */ + public hasChanges = computed(() => this.editedDocumentation.size > 0) + + /** + * Sets collection documentation + */ + public setCollectionDocumentation( + id: string, + documentation: string, + options: SetCollectionDocumentationOptions + ): void { + const key = `collection_${id}` + const item: CollectionDocumentationItem = { + type: "collection", + id, + documentation, + isTeamItem: options.isTeamItem, + teamID: options.teamID, + pathOrID: options.pathOrID, + collectionData: options.collectionData, + } + + this.editedDocumentation.set(key, item) + } + + /** + * Sets request documentation + */ + public setRequestDocumentation( + id: string, + documentation: string, + options: SetRequestDocumentationOptions + ): void { + const key = `request_${id}` + const item: RequestDocumentationItem = { + type: "request", + id, + documentation, + isTeamItem: options.isTeamItem, + teamID: options.teamID, + parentCollectionID: options.parentCollectionID, + folderPath: options.folderPath, + requestID: options.requestID, // Will be defined for team requests + requestIndex: options.requestIndex, // Will be defined for personal requests + requestData: options.requestData, + } + + this.editedDocumentation.set(key, item) + } + + /** + * Gets the documentation for a collection or request + * @param type The type of item ('collection' or 'request') + * @param id The ID of the collection or request + * @returns The documentation content or undefined if not found + */ + public getDocumentation( + type: DocumentationType, + id: string + ): string | undefined { + const key = `${type}_${id}` + const stored = this.editedDocumentation.get(key) + return stored?.documentation + } + + /** + * Gets the parent collection ID for a request documentation item + * @param id The ID of the request + * @returns The parent collection ID or undefined if not found or not a request + */ + public getParentCollectionID(id: string): string | undefined { + const key = `request_${id}` + const stored = this.editedDocumentation.get(key) + + if (stored?.type === "request") { + return stored.parentCollectionID + } + + return undefined + } + + /** + * Gets the complete documentation item with all metadata + * @param type The type of item ('collection' or 'request') + * @param id The ID of the collection or request + * @returns The complete documentation item or undefined if not found + */ + public getDocumentationItem( + type: DocumentationType, + id: string + ): DocumentationItem | undefined { + const key = `${type}_${id}` + return this.editedDocumentation.get(key) + } + + /** + * Gets all changed items as an array + * @returns Array of all items with changes + */ + public getChangedItems(): DocumentationItem[] { + return Array.from(this.editedDocumentation.values()) + } + + /** + * Clears all edited documentation + */ + public clearAll(): void { + this.editedDocumentation.clear() + } + + /** + * Removes a specific item from the edited documentation + * @param type The type of item ('collection' or 'request') + * @param id The ID of the collection or request + */ + public removeItem(type: DocumentationType, id: string): void { + const key = `${type}_${id}` + this.editedDocumentation.delete(key) + } + + /** + * Checks if a specific item has changes + * @param type The type of item ('collection' or 'request') + * @param id The ID of the collection or request + * @returns True if the item has changes + */ + public hasItemChanges(type: DocumentationType, id: string): boolean { + const key = `${type}_${id}` + return this.editedDocumentation.has(key) + } + + /** + * Gets the count of items with changes + * @returns Number of items with unsaved changes + */ + public getChangesCount(): number { + return this.editedDocumentation.size + } +} diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts index bfe5dd6d..4716f464 100644 --- a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts @@ -25,7 +25,7 @@ const DEFAULT_SETTINGS = getDefaultSettings() export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ { - v: 10, + v: 11, name: "Echo", requests: [ { @@ -47,18 +47,20 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ }, requestVariables: [], responses: {}, + description: null, }, ], auth: { authType: "none", authActive: true }, headers: [], variables: [], + description: null, folders: [], }, ] export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ { - v: 10, + v: 11, name: "Echo", requests: [ { @@ -77,6 +79,7 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ auth: { authType: "none", authActive: true }, headers: [], variables: [], + description: null, folders: [], }, ] @@ -173,6 +176,7 @@ export const REST_HISTORY_MOCK: RESTHistoryEntry[] = [ requestVariables: [], v: RESTReqSchemaVersion, responses: {}, + description: null, }, responseMeta: { duration: 807, statusCode: 200 }, star: false, @@ -240,6 +244,7 @@ export const REST_TAB_STATE_MOCK: PersistableTabState = { body: { contentType: null, body: null }, requestVariables: [], responses: {}, + description: null, _ref_id: "req_ref_id", }, isDirty: false, diff --git a/packages/hoppscotch-common/src/services/persistence/index.ts b/packages/hoppscotch-common/src/services/persistence/index.ts index da00b029..361fd102 100644 --- a/packages/hoppscotch-common/src/services/persistence/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/index.ts @@ -472,6 +472,7 @@ export class PersistenceService extends Service { if (result.success) { const translatedData = result.data.map(translateToNewRESTCollection) + setRESTCollections(translatedData) } else { console.error(`Failed with `, result.error, data) diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts index 036138fa..1dbd8957 100644 --- a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts @@ -86,6 +86,7 @@ const SettingsDefSchema = z.object({ EXPERIMENTAL_SCRIPTING_SANDBOX: z.optional(z.boolean()), ENABLE_EXPERIMENTAL_MOCK_SERVERS: z.optional(z.boolean()), + ENABLE_EXPERIMENTAL_DOCUMENTATION: z.optional(z.boolean()), }) const HoppRESTRequestSchema = entityReference(HoppRESTRequest) diff --git a/packages/hoppscotch-common/src/services/team-collection.service.ts b/packages/hoppscotch-common/src/services/team-collection.service.ts index 3ead4e43..dd47b04d 100644 --- a/packages/hoppscotch-common/src/services/team-collection.service.ts +++ b/packages/hoppscotch-common/src/services/team-collection.service.ts @@ -1234,4 +1234,68 @@ export class TeamCollectionsService extends Service { return { auth, headers, variables } } + + private async waitForCollectionLoading(collectionID: string) { + while (this.loadingCollections.value.includes(collectionID)) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + + /** + * Used to obtain the inherited auth and headers for a given folder path + * This function is async and will expand the collections if they are not expanded yet + * @param folderPath the path of the folder to cascade the auth from + * @returns the inherited auth and headers for the given folder path + */ + public async cascadeParentCollectionForPropertiesAsync(folderPath: string) { + if (!folderPath) + return { + auth: { + parentID: "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + }, + headers: [], + variables: [], + } + + const path = folderPath.split("/") + + // Check if the path is empty or invalid + if (!path || path.length === 0) { + console.error("Invalid path:", folderPath) + return { + auth: { + parentID: "", + parentName: "", + inheritedAuth: { + authType: "none", + authActive: true, + }, + }, + headers: [], + variables: [], + } + } + + // Loop through the path and expand the collections if they are not expanded + for (let i = 0; i < path.length; i++) { + const parentFolder = findCollInTree(this.collections.value, path[i]) + + if (parentFolder) { + if (parentFolder.children === null) { + if (this.loadingCollections.value.includes(parentFolder.id)) { + await this.waitForCollectionLoading(parentFolder.id) + } else { + await this.expandCollection(parentFolder.id) + } + } + } + } + + return this.cascadeParentCollectionForProperties(folderPath) + } } diff --git a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts index 5a7ecf23..87135610 100644 --- a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts +++ b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts @@ -65,6 +65,7 @@ export class TestRunnerService extends Service { folders: [], requests: [], variables: [], + description: collection.description ?? null, } this.runTestCollection(tab, collection, options) diff --git a/packages/hoppscotch-data/src/collection/index.ts b/packages/hoppscotch-data/src/collection/index.ts index 3ca375b4..ddb0a4e8 100644 --- a/packages/hoppscotch-data/src/collection/index.ts +++ b/packages/hoppscotch-data/src/collection/index.ts @@ -10,6 +10,7 @@ import V7_VERSION from "./v/7" import V8_VERSION from "./v/8" import V9_VERSION from "./v/9" import V10_VERSION from "./v/10" +import V11_VERSION from "./v/11" export { CollectionVariable } from "./v/10" @@ -23,7 +24,7 @@ const versionedObject = z.object({ }) export const HoppCollection = createVersionedEntity({ - latestVersion: 10, + latestVersion: 11, versionMap: { 1: V1_VERSION, 2: V2_VERSION, @@ -35,6 +36,7 @@ export const HoppCollection = createVersionedEntity({ 8: V8_VERSION, 9: V9_VERSION, 10: V10_VERSION, + 11: V11_VERSION, }, getVersion(data) { const versionCheck = versionedObject.safeParse(data) @@ -54,7 +56,7 @@ export type HoppCollectionVariable = InferredEntity< typeof HoppCollection >["variables"][number] -export const CollectionSchemaVersion = 10 +export const CollectionSchemaVersion = 11 /** * Generates a Collection object. This ignores the version number object @@ -84,6 +86,8 @@ export function translateToNewRESTCollection(x: any): HoppCollection { const headers = x.headers ?? [] const variables = x.variables ?? [] + const description = x.description ?? null + const obj = makeCollection({ name, folders, @@ -91,10 +95,13 @@ export function translateToNewRESTCollection(x: any): HoppCollection { auth, headers, variables, + description, }) if (x.id) obj.id = x.id - if (x._ref_id) obj._ref_id = x._ref_id + if (x._ref_id) { + obj._ref_id = x._ref_id + } return obj } @@ -114,6 +121,8 @@ export function translateToNewGQLCollection(x: any): HoppCollection { const headers = x.headers ?? [] const variables = x.variables ?? [] + const description = x.description ?? null + const obj = makeCollection({ name, folders, @@ -121,10 +130,13 @@ export function translateToNewGQLCollection(x: any): HoppCollection { auth, headers, variables, + description, }) if (x.id) obj.id = x.id - if (x._ref_id) obj._ref_id = x._ref_id + if (x._ref_id) { + obj._ref_id = x._ref_id + } return obj } diff --git a/packages/hoppscotch-data/src/collection/v/11.ts b/packages/hoppscotch-data/src/collection/v/11.ts new file mode 100644 index 00000000..3ce93120 --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/11.ts @@ -0,0 +1,45 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppCollection } from ".." +import { v10_baseCollectionSchema } from "./10" + +export const v11_baseCollectionSchema = v10_baseCollectionSchema.extend({ + v: z.literal(11), + description: z.string().nullable().catch(null), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V11_SCHEMA = v11_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 11))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V11_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 11 as const, + description: old.description ?? null, + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 11) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/rest/index.ts b/packages/hoppscotch-data/src/rest/index.ts index 012f7cb4..4aa46833 100644 --- a/packages/hoppscotch-data/src/rest/index.ts +++ b/packages/hoppscotch-data/src/rest/index.ts @@ -27,6 +27,7 @@ import V15_VERSION from "./v/15/index" import V16_VERSION from "./v/16" import { HoppRESTRequestResponses } from "../rest-request-response" import { generateUniqueRefId } from "../utils/collection" +import V17_VERSION from "./v/17" export * from "./content-types" @@ -77,7 +78,7 @@ const versionedObject = z.object({ }) export const HoppRESTRequest = createVersionedEntity({ - latestVersion: 16, + latestVersion: 17, versionMap: { 0: V0_VERSION, 1: V1_VERSION, @@ -96,6 +97,7 @@ export const HoppRESTRequest = createVersionedEntity({ 14: V14_VERSION, 15: V15_VERSION, 16: V16_VERSION, + 17: V17_VERSION, }, getVersion(data) { // For V1 onwards we have the v string storing the number @@ -137,9 +139,10 @@ const HoppRESTRequestEq = Eq.struct({ ), responses: lodashIsEqualEq, _ref_id: undefinedEq(S.Eq), + description: lodashIsEqualEq, }) -export const RESTReqSchemaVersion = "16" +export const RESTReqSchemaVersion = "17" export type HoppRESTParam = HoppRESTRequest["params"][number] export type HoppRESTHeader = HoppRESTRequest["headers"][number] @@ -227,6 +230,10 @@ export function safelyExtractRESTRequest( req.responses = result.data } } + + if ("description" in x && typeof x.description === "string") { + req.description = x.description + } } return req @@ -243,6 +250,7 @@ export function makeRESTRequest( } export function getDefaultRESTRequest(): HoppRESTRequest { + const ref_id = generateUniqueRefId("req") return { v: RESTReqSchemaVersion, endpoint: "https://echo.hoppscotch.io", @@ -262,7 +270,8 @@ export function getDefaultRESTRequest(): HoppRESTRequest { }, requestVariables: [], responses: {}, - _ref_id: generateUniqueRefId("req"), + _ref_id: ref_id, + description: null, } } diff --git a/packages/hoppscotch-data/src/rest/v/17.ts b/packages/hoppscotch-data/src/rest/v/17.ts new file mode 100644 index 00000000..b5381812 --- /dev/null +++ b/packages/hoppscotch-data/src/rest/v/17.ts @@ -0,0 +1,22 @@ +import { z } from "zod" +import { defineVersion } from "verzod" +import { V16_SCHEMA } from "./16" + +export const V17_SCHEMA = V16_SCHEMA.extend({ + v: z.literal("17"), + description: z.string().nullable().catch(null), +}) + +const V17_VERSION = defineVersion({ + schema: V17_SCHEMA, + initial: false, + up(old: z.infer) { + return { + ...old, + v: "17" as const, + description: old.description ?? null, + } + }, +}) + +export default V17_VERSION diff --git a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts index fe671c9c..51a4e527 100644 --- a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts +++ b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.platform.ts @@ -136,11 +136,12 @@ function exportedCollectionToHoppCollection( auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } return { id: restCollection.id, - v: 10, + v: 11, name: restCollection.name, folders: restCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -165,6 +166,7 @@ function exportedCollectionToHoppCollection( testScript, requestVariables, responses, + description, } = request const resolvedParams = addDescriptionField(params) @@ -184,11 +186,13 @@ function exportedCollectionToHoppCollection( preRequestScript, testScript, responses, + description, } }), auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, } } else { const gqlCollection = collection as ExportedUserCollectionGQL @@ -200,11 +204,12 @@ function exportedCollectionToHoppCollection( auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } return { id: gqlCollection.id, - v: 10, + v: 11, name: gqlCollection.name, folders: gqlCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -233,6 +238,7 @@ function exportedCollectionToHoppCollection( auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, } } } @@ -382,6 +388,7 @@ function setupUserCollectionCreatedSubscription() { auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } runDispatchWithOutSyncing(() => { @@ -390,19 +397,21 @@ function setupUserCollectionCreatedSubscription() { name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 10, + v: 11, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, }) : addRESTCollection({ name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 10, + v: 11, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, }) const localIndex = collectionStore.value.state.length - 1 @@ -605,13 +614,14 @@ function setupUserCollectionDuplicatedSubscription() { ) // Incoming data transformed to the respective internal representations - const { auth, headers, variables } = + const { auth, headers, variables, description } = data && data != "null" ? JSON.parse(data) : { auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } const folders = transformDuplicatedCollections(childCollectionsJSONStr) @@ -626,10 +636,11 @@ function setupUserCollectionDuplicatedSubscription() { name, folders, requests, - v: 10, + v: 11, auth, headers: addDescriptionField(headers), variables: variables ?? [], + description: description ?? null, } // only folders will have parent collection id @@ -1093,13 +1104,14 @@ function transformDuplicatedCollections( requests: userRequests, title: name, }) => { - const { auth, headers, variables } = + const { auth, headers, variables, description } = data && data !== "null" ? JSON.parse(data) : { auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } const folders = transformDuplicatedCollections(childCollectionsJSONStr) @@ -1111,10 +1123,11 @@ function transformDuplicatedCollections( name, folders, requests, - v: 10, + v: 11, auth, headers: addDescriptionField(headers), variables: variables ?? [], + description: description ?? null, } } ) diff --git a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.sync.ts b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.sync.ts index 8a0e88ea..9e25f6b8 100644 --- a/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.sync.ts +++ b/packages/hoppscotch-selfhost-desktop/src/platform/collections/collections.sync.ts @@ -61,6 +61,7 @@ const recursivelySyncCollections = async ( }, headers: collection.headers ?? [], variables: collection.variables ?? [], + description: collection.description ?? null, _ref_id: collection._ref_id, } const res = await createRESTRootUserCollection( @@ -81,6 +82,7 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: generateUniqueRefId("coll"), + description: null, } collection.id = parentCollectionID @@ -88,6 +90,7 @@ const recursivelySyncCollections = async ( collection.auth = returnedData.auth collection.headers = returnedData.headers collection.variables = returnedData.variables + collection.description = returnedData.description ?? null removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath) } else { parentCollectionID = undefined @@ -102,6 +105,7 @@ const recursivelySyncCollections = async ( }, headers: collection.headers ?? [], variables: collection.variables ?? [], + description: collection.description ?? null, _ref_id: collection._ref_id, } @@ -124,6 +128,7 @@ const recursivelySyncCollections = async ( headers: [], _ref_id: generateUniqueRefId("coll"), variables: [], + description: null, } collection.id = childCollectionId @@ -132,6 +137,7 @@ const recursivelySyncCollections = async ( collection.headers = returnedData.headers parentCollectionID = childCollectionId collection.variables = returnedData.variables + collection.description = returnedData.description ?? null removeDuplicateRESTCollectionOrFolder( childCollectionId, @@ -177,6 +183,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => { }, headers: collection.headers ?? [], variables: collection.variables ?? [], + description: collection.description ?? null, _ref_id: collection._ref_id, } @@ -254,6 +261,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: collection.headers, variables: collection.variables, _ref_id: collection._ref_id, + description: collection.description, } if (collectionID) { @@ -335,6 +343,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: folder.headers, variables: folder.variables, _ref_id: folder._ref_id, + description: folder.description, } if (folderID) { 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 59b77e20..a1aeaa61 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts @@ -140,12 +140,13 @@ function exportedCollectionToHoppCollection( headers: [], _ref_id: generateUniqueRefId("coll"), variables: [], + description: null, } return { id: restCollection.id, _ref_id: data._ref_id ?? generateUniqueRefId("coll"), - v: 10, + v: 11, name: restCollection.name, folders: restCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -170,6 +171,8 @@ function exportedCollectionToHoppCollection( testScript, requestVariables, responses, + description, + _ref_id, } = request const resolvedParams = addDescriptionField(params) @@ -189,11 +192,14 @@ function exportedCollectionToHoppCollection( preRequestScript, testScript, responses, + description: description ?? null, + _ref_id: _ref_id ?? generateUniqueRefId("req"), } }), auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, } } else { const gqlCollection = collection as ExportedUserCollectionGQL @@ -206,12 +212,13 @@ function exportedCollectionToHoppCollection( headers: [], _ref_id: generateUniqueRefId("coll"), variables: [], + description: null, } return { id: gqlCollection.id, _ref_id: data._ref_id ?? generateUniqueRefId("coll"), - v: 10, + v: 11, name: gqlCollection.name, folders: gqlCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -240,6 +247,7 @@ function exportedCollectionToHoppCollection( auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, } } } @@ -398,21 +406,23 @@ function setupUserCollectionCreatedSubscription() { name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 10, + v: 11, _ref_id: data._ref_id, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, }) : addRESTCollection({ name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 10, + v: 11, _ref_id: data._ref_id, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, }) const localIndex = collectionStore.value.state.length - 1 @@ -615,13 +625,14 @@ function setupUserCollectionDuplicatedSubscription() { ) // Incoming data transformed to the respective internal representations - const { auth, headers, variables } = + const { auth, headers, variables, description } = data && data != "null" ? JSON.parse(data) : { auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } // Duplicated collection will have a unique ref id const _ref_id = generateUniqueRefId("coll") @@ -638,11 +649,12 @@ function setupUserCollectionDuplicatedSubscription() { name, folders, requests, - v: 10, + v: 11, _ref_id, auth, headers: addDescriptionField(headers), variables: variables ?? [], + description: description ?? null, } // only folders will have parent collection id @@ -1106,13 +1118,14 @@ function transformDuplicatedCollections( requests: userRequests, title: name, }) => { - const { auth, headers, variables } = + const { auth, headers, variables, description } = data && data !== "null" ? JSON.parse(data) : { auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } const _ref_id = generateUniqueRefId("coll") @@ -1127,10 +1140,11 @@ function transformDuplicatedCollections( folders, requests, _ref_id, - v: 10, + v: 11, auth, headers: addDescriptionField(headers), variables: variables ?? [], + description: description ?? null, } } ) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts index 11a7c156..0477dbbf 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts @@ -47,6 +47,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => { headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, } return { @@ -81,6 +82,7 @@ const recursivelySyncCollections = async ( headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, } const res = await createRESTRootUserCollection( collection.name, @@ -99,6 +101,7 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: generateUniqueRefId("coll"), + description: null, } collection.id = parentCollectionID @@ -106,6 +109,7 @@ const recursivelySyncCollections = async ( collection.auth = returnedData.auth collection.headers = returnedData.headers collection.variables = returnedData.variables + collection.description = returnedData.description ?? null removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath) } else { parentCollectionID = undefined @@ -120,6 +124,7 @@ const recursivelySyncCollections = async ( headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, } const res = await createRESTChildUserCollection( @@ -141,6 +146,7 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: generateUniqueRefId("coll"), + description: null, } collection.id = childCollectionId @@ -149,6 +155,7 @@ const recursivelySyncCollections = async ( collection.headers = returnedData.headers parentCollectionID = childCollectionId collection.variables = returnedData.variables + collection.description = returnedData.description ?? null removeDuplicateRESTCollectionOrFolder( childCollectionId, @@ -260,6 +267,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: collection.headers, variables: collection.variables, _ref_id: collection._ref_id, + description: collection.description ?? null, } if (collectionID) { @@ -342,6 +350,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: folder.headers, variables: folder.variables, _ref_id: folder._ref_id, + description: folder.description ?? null, } if (folderID) { updateUserCollection(folderID, folderName, JSON.stringify(data)) 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 576cfcc2..5ce79bce 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts @@ -140,12 +140,13 @@ function exportedCollectionToHoppCollection( headers: [], _ref_id: generateUniqueRefId("coll"), variables: [], + description: null, } return { id: restCollection.id, _ref_id: data._ref_id ?? generateUniqueRefId("coll"), - v: 10, + v: 11, name: restCollection.name, folders: restCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -170,6 +171,7 @@ function exportedCollectionToHoppCollection( testScript, requestVariables, responses, + description, _ref_id, } = request @@ -190,9 +192,11 @@ function exportedCollectionToHoppCollection( preRequestScript, testScript, responses, + description: description ?? null, _ref_id: _ref_id ?? generateUniqueRefId("req"), } }), + description: data.description ?? null, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], @@ -208,12 +212,13 @@ function exportedCollectionToHoppCollection( headers: [], _ref_id: generateUniqueRefId("coll"), variables: [], + description: null, } return { id: gqlCollection.id, _ref_id: data._ref_id ?? generateUniqueRefId("coll"), - v: 10, + v: 11, name: gqlCollection.name, folders: gqlCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -242,6 +247,7 @@ function exportedCollectionToHoppCollection( auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, } } } @@ -392,6 +398,7 @@ function setupUserCollectionCreatedSubscription() { headers: [], _ref_id: generateUniqueRefId("coll"), variables: [], + description: null, } runDispatchWithOutSyncing(() => { @@ -400,21 +407,23 @@ function setupUserCollectionCreatedSubscription() { name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 10, + v: 11, _ref_id: data._ref_id, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, }) : addRESTCollection({ name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 10, + v: 11, _ref_id: data._ref_id, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + description: data.description ?? null, }) const localIndex = collectionStore.value.state.length - 1 @@ -617,13 +626,14 @@ function setupUserCollectionDuplicatedSubscription() { ) // Incoming data transformed to the respective internal representations - const { auth, headers, variables } = + const { auth, headers, variables, description } = data && data != "null" ? JSON.parse(data) : { auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } // Duplicated collection will have a unique ref id const _ref_id = generateUniqueRefId("coll") @@ -640,11 +650,12 @@ function setupUserCollectionDuplicatedSubscription() { name, folders, requests, - v: 10, + v: 11, _ref_id, auth, headers: addDescriptionField(headers), variables: variables ?? [], + description: description ?? null, } // only folders will have parent collection id @@ -1108,13 +1119,14 @@ function transformDuplicatedCollections( requests: userRequests, title: name, }) => { - const { auth, headers, variables } = + const { auth, headers, variables, description } = data && data !== "null" ? JSON.parse(data) : { auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + description: null, } const _ref_id = generateUniqueRefId("coll") @@ -1129,10 +1141,11 @@ function transformDuplicatedCollections( folders, requests, _ref_id, - v: 10, + v: 11, auth, headers: addDescriptionField(headers), variables: variables ?? [], + description: description ?? null, } } ) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts index 3db492bb..a02fff16 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts @@ -47,6 +47,7 @@ const transformCollectionForBackend = (collection: HoppCollection): any => { headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, } return { @@ -81,6 +82,7 @@ const recursivelySyncCollections = async ( headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, } const res = await createRESTRootUserCollection( collection.name, @@ -99,6 +101,7 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: generateUniqueRefId("coll"), + description: null, } collection.id = parentCollectionID @@ -106,6 +109,7 @@ const recursivelySyncCollections = async ( collection.auth = returnedData.auth collection.headers = returnedData.headers collection.variables = returnedData.variables + collection.description = returnedData.description ?? null removeDuplicateRESTCollectionOrFolder( parentCollectionID, `${collectionPath}` @@ -123,6 +127,7 @@ const recursivelySyncCollections = async ( headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, } const res = await createRESTChildUserCollection( @@ -144,6 +149,7 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: generateUniqueRefId("coll"), + description: null, } collection.id = childCollectionId @@ -152,6 +158,7 @@ const recursivelySyncCollections = async ( collection.headers = returnedData.headers parentCollectionID = childCollectionId collection.variables = returnedData.variables + collection.description = returnedData.description ?? null removeDuplicateRESTCollectionOrFolder( childCollectionId, @@ -263,6 +270,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: collection.headers, variables: collection.variables, _ref_id: collection._ref_id, + description: collection.description ?? null, } if (collectionID) { @@ -346,6 +354,7 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: folder.headers, variables: folder.variables, _ref_id: folder._ref_id, + description: folder.description, } if (folderID) { updateUserCollection(folderID, folderName, JSON.stringify(data)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd11801f..e964ba7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -596,6 +596,9 @@ importers: dioc: specifier: 3.0.2 version: 3.0.2(vue@3.5.22(typescript@5.9.3)) + dompurify: + specifier: 3.3.0 + version: 3.3.0 esprima: specifier: 4.0.1 version: 4.0.1 @@ -620,6 +623,12 @@ importers: hawk: specifier: 9.0.2 version: 9.0.2 + highlight.js: + specifier: 11.11.1 + version: 11.11.1 + highlightjs-curl: + specifier: 1.3.0 + version: 1.3.0 insomnia-importers: specifier: 3.6.0 version: 3.6.0(openapi-types@12.1.3) @@ -8311,6 +8320,9 @@ packages: dompurify@3.2.7: resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dompurify@3.3.0: + resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==} + domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} @@ -9415,6 +9427,13 @@ packages: header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + highlightjs-curl@1.3.0: + resolution: {integrity: sha512-50UEfZq1KR0Lfk2Tr6xb/MUIZH3h10oNC0OTy9g7WELcs5Fgy/mKN1vEhuKTkKbdo8vr5F9GXstu2eLhApfQ3A==} + hono@4.10.3: resolution: {integrity: sha512-2LOYWUbnhdxdL8MNbNg9XZig6k+cZXm5IjHn2Aviv7honhBMOHb+jxrKIeJRZJRmn+htUCKhaicxwXuUDlchRA==} engines: {node: '>=16.9.0'} @@ -22214,6 +22233,10 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.3.0: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@2.8.0: dependencies: dom-serializer: 1.4.1 @@ -23724,6 +23747,10 @@ snapshots: capital-case: 1.0.4 tslib: 2.8.1 + highlight.js@11.11.1: {} + + highlightjs-curl@1.3.0: {} + hono@4.10.3: {} hookable@5.5.3: {}