diff --git a/packages/api-gateway/src/graphql-common-utils.ts b/packages/api-gateway/src/graphql-common-utils.ts new file mode 100644 index 0000000..f934b41 --- /dev/null +++ b/packages/api-gateway/src/graphql-common-utils.ts @@ -0,0 +1,121 @@ +import { Connection, ConnectionCursor } from 'graphql-relay'; +import type { QueryInput } from '@aloxide/demux'; + +export interface PageInfoInput { + entityName: string; + edges: any[]; + limit: number; + offset?: string; +} + +const NUMER_OF_ITEMS_PER_PAGE = 20; + +export type Base64String = string; + +export function base64(i: string): Base64String { + return Buffer.from(i, 'utf8').toString('base64'); +} + +export function unbase64(i: Base64String): string { + return Buffer.from(i, 'base64').toString('utf8'); +} + +/** + * Creates the cursor string from an offset. + */ +export function offsetToCursor(prefix: string, offset: number): ConnectionCursor { + return base64(prefix + offset); +} +/** + * Rederives the offset from the cursor string. + */ +export function cursorToOffset(prefix: string, cursor: ConnectionCursor): number { + if (cursor) return parseInt(unbase64(cursor).substring(prefix.length), 10); + return NaN; +} + +export function convertCursorToOffet(entityName: string, args: any): QueryInput { + const updatedArgs: QueryInput = { ...args }; + + const beforOffset = cursorToOffset(entityName, updatedArgs.before); + const afterOffset = cursorToOffset(entityName, updatedArgs.after); + + if (isNaN(beforOffset)) delete updatedArgs.before; + else updatedArgs.before = beforOffset.toString(); + + if (isNaN(afterOffset)) delete updatedArgs.after; + else updatedArgs.after = afterOffset.toString(); + + if (updatedArgs.first) updatedArgs.first += 1; + if (updatedArgs.last) updatedArgs.last += 1; + + return updatedArgs; +} + +export function paginationInfo( + entityName: string, + items: any[], + args: QueryInput, +): Connection { + if (args.first) { + return forwardPaginationInfo({ + entityName, + edges: items, + limit: args.first, + offset: args.after, + }); + } + + if (args.last) { + return backwardPaginationInfo({ + entityName, + edges: items, + limit: args.last, + offset: args.before, + }); + } + + return forwardPaginationInfo({ entityName, edges: items, limit: NUMER_OF_ITEMS_PER_PAGE }); +} + +export function forwardPaginationInfo(pageInput: PageInfoInput): Connection { + let hasNextPage = false; + if (pageInput.edges.length > pageInput.limit) { + pageInput.edges = pageInput.edges.slice(0, pageInput.limit); + hasNextPage = true; + } + const edges = pageInput.edges.map(value => ({ + cursor: offsetToCursor(pageInput.entityName, value.id), + node: value, + })); + return { + edges, + pageInfo: { + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + hasNextPage, + hasPreviousPage: pageInput.offset ? true : false, + }, + }; +} + +export function backwardPaginationInfo(pageInput: PageInfoInput): Connection { + let hasPreviousPage = false; + if (pageInput.edges.length > pageInput.limit) { + pageInput.edges = pageInput.edges.slice(0, pageInput.limit); + hasPreviousPage = true; + } + const edges = pageInput.edges.reverse().map(value => ({ + cursor: offsetToCursor(pageInput.entityName, value.id), + node: value, + })); + return { + edges, + pageInfo: { + startCursor: edges[0]?.cursor, + endCursor: edges[edges.length - 1]?.cursor, + hasNextPage: pageInput.offset ? true : false, + hasPreviousPage, + }, + }; +} diff --git a/packages/api-gateway/src/graphql-relay.ts b/packages/api-gateway/src/graphql-relay.ts index a0b9dc7..8931a2f 100644 --- a/packages/api-gateway/src/graphql-relay.ts +++ b/packages/api-gateway/src/graphql-relay.ts @@ -1,14 +1,10 @@ import { FieldTypeEnum, Interpreter } from '@aloxide/bridge'; import { AloxideDataManager } from '@aloxide/demux'; import { GraphQLObjectType } from 'graphql'; -import { - connectionArgs, - ConnectionConfigNodeType, - connectionDefinitions, - connectionFromArray, -} from 'graphql-relay'; +import { connectionArgs, ConnectionConfigNodeType, connectionDefinitions } from 'graphql-relay'; import { GraphqlTypeInterpreter } from './GraphqlTypeInterpreter'; +import { convertCursorToOffet, paginationInfo } from './graphql-common-utils'; import type { GraphQLFieldConfig, GraphQLScalarType, GraphQLNamedType } from 'graphql'; import type { GraphQLConnectionDefinitions } from 'graphql-relay'; @@ -79,11 +75,10 @@ export function createGraphQl(config: CreateGraphQlConfig): CreateGraphQlOutput[ type: connectionType, args: connectionArgs, resolve: (_, args) => { - const queryInput: QueryInput = args; - // TODO fix find all + const queryInput: QueryInput = convertCursorToOffet(connectionType.name, args); return dataAdapter .findAll(name, queryInput, metaData) - .then(items => connectionFromArray(items, args)); + .then(items => paginationInfo(connectionType.name, items, args)); }, }; diff --git a/packages/api-gateway/test/graphql-common-ultils.test.ts b/packages/api-gateway/test/graphql-common-ultils.test.ts new file mode 100644 index 0000000..67f0aa1 --- /dev/null +++ b/packages/api-gateway/test/graphql-common-ultils.test.ts @@ -0,0 +1,167 @@ +import { QueryInput } from '@aloxide/demux/src'; +import { + base64, + unbase64, + offsetToCursor, + cursorToOffset, + convertCursorToOffet, + forwardPaginationInfo, + backwardPaginationInfo, + paginationInfo, +} from '../src/graphql-common-utils'; + +describe('Graphql Common Ultils', () => { + const entityName = 'test'; + + it('Should convert to base64', () => { + expect(base64('hello')).toBe('aGVsbG8='); + }); + + it('Should revert to string', () => { + expect(unbase64('aGVsbG8=')).toBe('hello'); + }); + + it('Should parse offset to cursor', () => { + expect(offsetToCursor(entityName, 1)).toBe('dGVzdDE='); + }); + + it('Should revert cursor to offset', () => { + expect(cursorToOffset(entityName, 'dGVzdDE=')).toBe(1); + }); + + it('Revert offset Should return NaN', () => { + expect(cursorToOffset(entityName, 'hello')).toBeNaN(); + }); + + it('Should convert cursor to offset', () => { + const input: QueryInput = { + first: 2, + after: 'test', + last: 2, + before: 'test', + }; + + const resp = convertCursorToOffet(entityName, input); + expect(resp.first).toBe(3); + expect(resp.last).toBe(3); + expect(resp.after).toBeUndefined(); + expect(resp.before).toBeUndefined(); + }); + + it('Should convert cursor to offset', () => { + const input: QueryInput = { + first: 2, + after: 'dGVzdDE=', + last: 2, + before: 'dGVzdDE=', + }; + + const resp = convertCursorToOffet(entityName, input); + expect(resp.first).toBe(3); + expect(resp.last).toBe(3); + expect(resp.after).toBe('1'); + expect(resp.before).toBe('1'); + }); + + it('Should forward pagination', () => { + const items = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + ]; + + const resp = paginationInfo(entityName, items, { first: 2 }); + + expect(resp.edges).toBeDefined(); + expect(resp.edges[0].cursor).toBe('dGVzdDE='); + expect(resp.pageInfo.startCursor).toBe('dGVzdDE='); + expect(resp.pageInfo.endCursor).toBe('dGVzdDI='); + expect(resp.pageInfo.hasNextPage).toBe(true); + expect(resp.pageInfo.hasPreviousPage).toBe(false); + }); + + it('Should forward pagination with after', () => { + const items = [ + { + id: 1, + }, + { + id: 2, + }, + { + id: 3, + }, + ]; + + const resp = paginationInfo(entityName, items, { first: 2, after: 'test' }); + + expect(resp.edges).toBeDefined(); + expect(resp.edges[0].cursor).toBe('dGVzdDE='); + expect(resp.pageInfo.startCursor).toBe('dGVzdDE='); + expect(resp.pageInfo.endCursor).toBe('dGVzdDI='); + expect(resp.pageInfo.hasNextPage).toBe(true); + expect(resp.pageInfo.hasPreviousPage).toBe(true); + }); + + it('Should backward pagination', () => { + const items = [ + { + id: 37, + }, + { + id: 36, + }, + { + id: 35, + }, + ]; + + const resp = paginationInfo(entityName, items, { last: 2 }); + + expect(resp.edges).toBeDefined(); + expect(resp.edges[0].cursor).toBe('dGVzdDM2'); + expect(resp.pageInfo.startCursor).toBe('dGVzdDM2'); + expect(resp.pageInfo.endCursor).toBe('dGVzdDM3'); + expect(resp.pageInfo.hasNextPage).toBe(false); + expect(resp.pageInfo.hasPreviousPage).toBe(true); + }); + + it('Should backward pagination with before', () => { + const items = [ + { + id: 37, + }, + { + id: 36, + }, + { + id: 35, + }, + ]; + + const resp = paginationInfo(entityName, items, { last: 2, before: 'test' }); + + expect(resp.edges).toBeDefined(); + expect(resp.edges[0].cursor).toBe('dGVzdDM2'); + expect(resp.pageInfo.startCursor).toBe('dGVzdDM2'); + expect(resp.pageInfo.endCursor).toBe('dGVzdDM3'); + expect(resp.pageInfo.hasNextPage).toBe(true); + expect(resp.pageInfo.hasPreviousPage).toBe(true); + }); + + it('Should backward pagination by emtpy items', () => { + const resp = paginationInfo(entityName, [], { last: 2, before: 'test' }); + + expect(resp.edges).toBeDefined(); + expect(resp.pageInfo.startCursor).toBeUndefined(); + expect(resp.pageInfo.endCursor).toBeUndefined(); + expect(resp.pageInfo.hasNextPage).toBe(true); + expect(resp.pageInfo.hasPreviousPage).toBe(false); + }); +}); diff --git a/packages/example-api-gateway/src/models.ts b/packages/example-api-gateway/src/models.ts index d8f4200..9bbcc62 100644 --- a/packages/example-api-gateway/src/models.ts +++ b/packages/example-api-gateway/src/models.ts @@ -57,15 +57,28 @@ export function createDataProvider( return m.count(); }, - findAll({ limit, after }, { entity: { key } }): Promise { - return m.findAll({ - limit, - where: after && { - [key]: { - [Op.gt]: after, + findAll({ first, after, last, before }, { entity: { key } }): Promise { + if (first) + return m.findAll({ + limit: first, + where: after && { + [key]: { + [Op.gt]: after, + }, }, - }, - }); + order: [[key, 'asc']], + }); + + if (last) + return m.findAll({ + limit: last, + where: before && { + [key]: { + [Op.lt]: before, + }, + }, + order: [[key, 'desc']], + }); }, find(id: any, meta?: any): Promise {