From 51e842000aeada2b806f15e26bd64affd38c5c6c Mon Sep 17 00:00:00 2001 From: forehalo Date: Tue, 18 Feb 2025 07:15:51 +0000 Subject: [PATCH] fix(server): pagination input parser (#10245) --- .../__snapshots__/pagination.spec.ts.md | 81 +++++++++++++ .../__snapshots__/pagination.spec.ts.snap | Bin 0 -> 754 bytes .../base/graphql/__tests__/pagination.spec.ts | 106 ++++++++++++++++++ .../server/src/base/graphql/pagination.ts | 42 +++---- .../src/core/workspaces/resolvers/doc.ts | 2 +- 5 files changed, 209 insertions(+), 22 deletions(-) create mode 100644 packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.md create mode 100644 packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.snap create mode 100644 packages/backend/server/src/base/graphql/__tests__/pagination.spec.ts diff --git a/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.md b/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.md new file mode 100644 index 0000000000..b04e248d2e --- /dev/null +++ b/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.md @@ -0,0 +1,81 @@ +# Snapshot report for `src/base/graphql/__tests__/pagination.spec.ts` + +The actual snapshot is saved in `pagination.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## should return encode pageInfo + +> Snapshot 1 + + { + edges: [ + { + cursor: 'MTE=', + node: { + id: 11, + }, + }, + { + cursor: 'MTI=', + node: { + id: 12, + }, + }, + { + cursor: 'MTM=', + node: { + id: 13, + }, + }, + { + cursor: 'MTQ=', + node: { + id: 14, + }, + }, + { + cursor: 'MTU=', + node: { + id: 15, + }, + }, + { + cursor: 'MTY=', + node: { + id: 16, + }, + }, + { + cursor: 'MTc=', + node: { + id: 17, + }, + }, + { + cursor: 'MTg=', + node: { + id: 18, + }, + }, + { + cursor: 'MTk=', + node: { + id: 19, + }, + }, + { + cursor: 'MjA=', + node: { + id: 20, + }, + }, + ], + pageInfo: { + endCursor: 'MjA=', + hasNextPage: true, + hasPreviousPage: true, + startCursor: 'MTE=', + }, + totalCount: 105, + } diff --git a/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.snap b/packages/backend/server/src/base/graphql/__tests__/__snapshots__/pagination.spec.ts.snap new file mode 100644 index 0000000000000000000000000000000000000000..8cc73eeffe0bb1873fbb1ac331d60ab3bf9893c9 GIT binary patch literal 754 zcmVl|66MP!xvWW8Z5#aT6y^0f`T(140#IfQ6|=Dy5VnO3T;K zfx(U4ByET*+o{TeRH+CFkoW>_|+0Cxa908k^NO2+iuf?tn`eqp6j0hK^Hs7&+%z(B&h zO5FDvek97D{wP}q1ArXVNIR$(;~;k1V?nnQixQ(&GKXywP$l350cQxfK)^5o69n8Q z;64FO0wUUNZ1mL0`z7|Y*xcYGw-p#EM5KI*`pE^SUf zyg7?>lK~|LoM)2C74<3uZZIH_RZ~$PGvFBm-pFb}QMVYd!+>wHYAdSFfdU84aY-#H z>SYdGja+vpYh2w?&pk8rSa!E?CHOkh{m3`JuWP_!h?Qnb?+a8}leigv{UuFG1V zqInjuAZuksTkFmCRMskr_PRIQrmXcV+E#D29a$St_tu+jSJsXy&pt_qsI|QPKUF)U k- ({ id: i + 1 })); +const paginationStub = Sinon.stub().callsFake(input => { + const start = input.offset + (input.after ? parseInt(input.after) : 0); + return paginate( + ITEMS.slice(start, start + input.first), + 'id', + input, + TOTAL_COUNT + ); +}); + +const query = `query pagination($input: PaginationInput) { + pagination(paginationInput: $input) { + totalCount + edges { + cursor + node { + id + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } +}`; + +@ObjectType() +class TestType { + @Field() + id!: number; +} + +@ObjectType() +class PaginatedTestType extends Paginated(TestType) {} + +@Public() +@Resolver(() => TestType) +class TestResolver { + @Query(() => PaginatedTestType) + async pagination( + @Args( + 'paginationInput', + { + type: () => PaginationInput, + defaultValue: { first: 10, offset: 0 }, + }, + PaginationInput.decode + ) + input: PaginationInput + ) { + return paginationStub(input); + } +} + +const app = await createTestingApp({ + providers: [TestResolver], +}); + +test.after.always(async () => { + await app.close(); +}); + +test('should decode pagination input', async t => { + await app.gql(query, { + input: { + first: 5, + offset: 1, + after: Buffer.from('4').toString('base64'), + }, + }); + + t.true( + paginationStub.calledOnceWithExactly({ + first: 5, + offset: 1, + after: '4', + }) + ); +}); + +test('should return encode pageInfo', async t => { + const result = paginate( + ITEMS.slice(10, 20), + 'id', + { + first: 10, + offset: 0, + after: '9', + }, + TOTAL_COUNT + ); + + t.snapshot(result); +}); diff --git a/packages/backend/server/src/base/graphql/pagination.ts b/packages/backend/server/src/base/graphql/pagination.ts index b8cfe9bef5..1c0fde85b8 100644 --- a/packages/backend/server/src/base/graphql/pagination.ts +++ b/packages/backend/server/src/base/graphql/pagination.ts @@ -1,24 +1,26 @@ -import { Type } from '@nestjs/common'; -import { - Field, - FieldMiddleware, - InputType, - Int, - MiddlewareContext, - NextFn, - ObjectType, -} from '@nestjs/graphql'; - -const parseCursorMiddleware: FieldMiddleware = async ( - _ctx: MiddlewareContext, - next: NextFn -) => { - const value = await next(); - return value === undefined || value === null ? null : decode(value); -}; +import { PipeTransform, Type } from '@nestjs/common'; +import { Field, InputType, Int, ObjectType } from '@nestjs/graphql'; @InputType() export class PaginationInput { + /** + * Because there is no resolver for GraphQL's InputTypes, we can't automatically decode the cursor input from base64 values. + * Use this helper as `PipeTransform` to transform input args + * + * @example + * + * paginate(@Args('input', PaginationInput.decode) PaginationInput) {} + */ + static decode: PipeTransform = { + transform: value => { + return { + ...value, + after: value.after ? decode(value.after) : null, + // before: value.before ? decode(value.before) : null, + }; + }, + }; + @Field(() => Int, { nullable: true, description: 'returns the first n elements from the list.', @@ -37,7 +39,6 @@ export class PaginationInput { nullable: true, description: 'returns the elements in the list that come after the specified cursor.', - middleware: [parseCursorMiddleware], }) after?: string | null; @@ -46,7 +47,6 @@ export class PaginationInput { // nullable: true, // description: // 'returns the elements in the list that come before the specified cursor.', - // middleware: [parseCursorMiddleware], // }) // before?: string | null; } @@ -71,7 +71,7 @@ export function paginate( edges, pageInfo: { hasNextPage: edges.length >= paginationInput.first, - hasPreviousPage: paginationInput.offset > 0, + hasPreviousPage: !!paginationInput.after || paginationInput.offset > 0, endCursor: edges.length ? edges[edges.length - 1].cursor : null, startCursor: edges.length ? edges[0].cursor : null, }, diff --git a/packages/backend/server/src/core/workspaces/resolvers/doc.ts b/packages/backend/server/src/core/workspaces/resolvers/doc.ts index 3f933ac5d8..95babf4096 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/doc.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/doc.ts @@ -384,7 +384,7 @@ export class DocResolver { async grantedUsersList( @CurrentUser() user: CurrentUser, @Parent() doc: DocType, - @Args('pagination') pagination: PaginationInput + @Args('pagination', PaginationInput.decode) pagination: PaginationInput ): Promise { await this.permission.checkPagePermission( doc.workspaceId,