From 5129a356461633c5049a32e8fede8f79bf172044 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Fri, 6 Mar 2020 13:04:57 +1300 Subject: [PATCH 1/5] Add Relay type classes + barrelsby --- barrelsby.json | 7 +++++++ src/index.ts | 5 +++++ src/type/Connection.ts | 39 ++++++++++++++++++++++++++++++++++++ src/type/ConnectionArgs.ts | 28 ++++++++++++++++++++++++++ src/type/Edge.ts | 41 ++++++++++++++++++++++++++++++++++++++ src/type/PageInfo.ts | 20 +++++++++++++++++++ src/type/index.ts | 8 ++++++++ 7 files changed, 148 insertions(+) create mode 100644 barrelsby.json create mode 100644 src/index.ts create mode 100644 src/type/Connection.ts create mode 100644 src/type/ConnectionArgs.ts create mode 100644 src/type/Edge.ts create mode 100644 src/type/PageInfo.ts create mode 100644 src/type/index.ts diff --git a/barrelsby.json b/barrelsby.json new file mode 100644 index 00000000..fcd5d580 --- /dev/null +++ b/barrelsby.json @@ -0,0 +1,7 @@ +{ + "directory": "src/", + "location": "all", + "structure": "flat", + "singleQuotes": true, + "delete": true +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..6381c8a1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './type/index'; diff --git a/src/type/Connection.ts b/src/type/Connection.ts new file mode 100644 index 00000000..8969fbbd --- /dev/null +++ b/src/type/Connection.ts @@ -0,0 +1,39 @@ +import * as GQL from 'type-graphql'; +import * as Relay from 'graphql-relay'; +import { EdgeInterface } from './Edge'; +import { PageInfo } from './PageInfo'; + +export interface ConnectionInterface extends Relay.Connection { + pageInfo: PageInfo; + edges: EdgeInterface[]; +} + +export function createConnectionType( + TEdgeClass: new (fields?: Partial>) => EdgeInterface, +): new (fields?: Partial>) => ConnectionInterface { + // This class should be further extended by concrete Connection types. It can't be marked as + // an abstract class because TS lacks support for returning `abstract new()...` as a type + // (https://github.com/Microsoft/TypeScript/issues/25606) + @GQL.ObjectType({ isAbstract: true }) + class Connection implements ConnectionInterface { + @GQL.Field(_type => PageInfo) + public pageInfo: PageInfo; + + @GQL.Field(_type => [TEdgeClass]) + public edges: EdgeInterface[]; + + constructor(fields?: Partial>) { + if (fields != null) { + if (fields.pageInfo != null) { + this.pageInfo = fields.pageInfo; + } + + if (fields.edges != null) { + this.edges = fields.edges; + } + } + } + } + + return Connection; +} diff --git a/src/type/ConnectionArgs.ts b/src/type/ConnectionArgs.ts new file mode 100644 index 00000000..0fd5ea68 --- /dev/null +++ b/src/type/ConnectionArgs.ts @@ -0,0 +1,28 @@ +import * as GQL from 'type-graphql'; + +@GQL.ArgsType() +export class ConnectionArgs { + @GQL.Field({ + nullable: true, + description: 'Retrieve page of edges before opaque cursor.', + }) + public before?: string; + + @GQL.Field({ + nullable: true, + description: 'Retrieve page of edges after opaque cursor.', + }) + public after?: string; + + @GQL.Field(_type => GQL.Int, { + nullable: true, + description: 'Retrieve first `n` edges.', + }) + public first?: number; + + @GQL.Field(_type => GQL.Int, { + nullable: true, + description: 'Retrieve last `n` edges.', + }) + public last?: number; +} diff --git a/src/type/Edge.ts b/src/type/Edge.ts new file mode 100644 index 00000000..00068614 --- /dev/null +++ b/src/type/Edge.ts @@ -0,0 +1,41 @@ +import * as Relay from 'graphql-relay'; +import * as GQL from 'type-graphql'; + +export interface EdgeInterface extends Relay.Edge { + node: TNode; + cursor: string; +} + +export function createEdgeType( + TNodeClass: new () => TNode, +): new (fields?: Partial>) => EdgeInterface { + // This class should be further extended by concrete Edge types. It can't be marked as + // an abstract class because TS lacks support for returning `abstract new()...` as a type + // (https://github.com/Microsoft/TypeScript/issues/25606) + @GQL.ObjectType({ isAbstract: true }) + class Edge implements EdgeInterface { + @GQL.Field(_type => TNodeClass, { + description: `The node object (belonging to type ${TNodeClass.name}) attached to the edge.`, + }) + public node: TNode; + + @GQL.Field(_type => String, { + description: 'An opaque cursor that can be used to retrieve further pages of edges before or after this one.', + }) + public cursor: string; + + constructor(fields?: Partial>) { + if (fields != null) { + if (fields.node != null) { + this.node = fields.node; + } + + if (fields.cursor != null) { + this.cursor = fields.cursor; + } + } + } + } + + return Edge; +} diff --git a/src/type/PageInfo.ts b/src/type/PageInfo.ts new file mode 100644 index 00000000..ba60180a --- /dev/null +++ b/src/type/PageInfo.ts @@ -0,0 +1,20 @@ +import * as Relay from 'graphql-relay'; +import * as GQL from 'type-graphql'; + +@GQL.ObjectType() +export class PageInfo implements Relay.PageInfo { + @GQL.Field() + public hasNextPage!: boolean; + + @GQL.Field() + public hasPreviousPage!: boolean; + + @GQL.Field({ nullable: true }) + public startCursor?: string | null; + + @GQL.Field({ nullable: true }) + public endCursor?: string | null; + + @GQL.Field(_type => GQL.Int, { nullable: true }) + public totalEdges?: number | null; +} diff --git a/src/type/index.ts b/src/type/index.ts new file mode 100644 index 00000000..a3e3b7c5 --- /dev/null +++ b/src/type/index.ts @@ -0,0 +1,8 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './Connection'; +export * from './ConnectionArgs'; +export * from './Edge'; +export * from './PageInfo'; From 8c231b33fc7e8a3d17f5aa77ad6c4d54d3623760 Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Fri, 6 Mar 2020 13:06:34 +1300 Subject: [PATCH 2/5] Add cursor and paginator classes --- src/cursor/Cursor.ts | 43 ++++++++ src/cursor/OffsetCursorPaginator.ts | 115 +++++++++++++++++++++ src/cursor/index.ts | 7 ++ src/error/ConnectionArgsValidationError.ts | 1 + src/error/CursorValidationError.ts | 1 + src/error/index.ts | 6 ++ src/index.ts | 2 + 7 files changed, 175 insertions(+) create mode 100644 src/cursor/Cursor.ts create mode 100644 src/cursor/OffsetCursorPaginator.ts create mode 100644 src/cursor/index.ts create mode 100644 src/error/ConnectionArgsValidationError.ts create mode 100644 src/error/CursorValidationError.ts create mode 100644 src/error/index.ts diff --git a/src/cursor/Cursor.ts b/src/cursor/Cursor.ts new file mode 100644 index 00000000..3016c443 --- /dev/null +++ b/src/cursor/Cursor.ts @@ -0,0 +1,43 @@ +import * as querystring from 'querystring'; +import Joi from 'joi'; + +export class Cursor { + constructor(public readonly parameters: querystring.ParsedUrlQueryInput) {} + + public toString(): string { + return querystring.stringify(this.parameters, '&', '='); + } + + public encode(): string { + return Buffer.from(this.toString()).toString('base64'); + } + + public static decode(encodedString: string): querystring.ParsedUrlQuery { + // opaque cursors are base64 encoded, decode it first + const decodedString = Buffer.from(encodedString, 'base64').toString(); + + // cursor string is URL encoded, parse it into a map of parameters + return querystring.parse(decodedString, '&', '=', { + maxKeys: 20, + }); + } + + public static create(encodedString: string, schema: Joi.ObjectSchema): Cursor { + // opaque cursors are base64 encoded, decode it first + const decodedString = Buffer.from(encodedString, 'base64').toString(); + + // cursor string is URL encoded, parse it into a map of parameters + const parameters = querystring.parse(decodedString, '&', '=', { + maxKeys: 20, + }); + + // validate the cursor parameters match the schema we expect, this also converts data types + const { error, value: validatedParameters } = Joi.validate(parameters, schema); + + if (error != null) { + throw error; + } + + return new Cursor(validatedParameters); + } +} diff --git a/src/cursor/OffsetCursorPaginator.ts b/src/cursor/OffsetCursorPaginator.ts new file mode 100644 index 00000000..ffddb326 --- /dev/null +++ b/src/cursor/OffsetCursorPaginator.ts @@ -0,0 +1,115 @@ +import Joi from 'joi'; +import { ConnectionArgs, PageInfo } from '../type'; +import { Cursor } from './Cursor'; +import { ConnectionArgsValidationError, CursorValidationError } from '../error'; + +export class OffsetCursor extends Cursor { + public parameters: { + offset: number; + }; + + public static create(encodedString: string): OffsetCursor { + const parameters = Cursor.decode(encodedString); + + // validate the cursor parameters match the schema we expect, this also converts data types + const { error, value: validatedParameters } = Joi.validate( + parameters, + Joi.object({ + offset: Joi.number() + .integer() + .min(0) + .empty('') + .required(), + }).unknown(false), + ); + + if (error != null) { + const errorMessages = + error.details != null ? error.details.map(detail => `- ${detail.message}`).join('\n') : `- ${error.message}`; + + throw new CursorValidationError( + `A provided cursor value is not valid. The following problems were found:\n\n${errorMessages}`, + ); + } + + return new OffsetCursor(validatedParameters); + } +} + +export class OffsetCursorPaginator { + public take: number = 20; + public skip: number = 0; + public totalEdges: number = 0; + + constructor({ take, skip, totalEdges }: Pick) { + this.take = take; + this.skip = skip; + this.totalEdges = totalEdges; + } + + public createPageInfo(edgesInPage: number): PageInfo { + return { + startCursor: edgesInPage > 0 ? this.createCursor(0).encode() : null, + endCursor: edgesInPage > 0 ? this.createCursor(edgesInPage - 1).encode() : null, + hasNextPage: this.skip + edgesInPage < this.totalEdges, + hasPreviousPage: this.skip > 0, + totalEdges: this.totalEdges, + }; + } + + public createCursor(index: number): OffsetCursor { + return new OffsetCursor({ offset: this.skip + index }); + } + + public static createFromConnectionArgs( + { first, last, before, after }: ConnectionArgs, + totalEdges: number, + ): OffsetCursorPaginator { + let take: number = 20; + let skip: number = 0; + + if (first != null) { + if (first > 100 || first < 1) { + throw new ConnectionArgsValidationError('The "first" argument accepts a value between 1 and 100, inclusive.'); + } + + take = first; + skip = 0; + } + + if (last != null) { + if (first != null) { + throw new ConnectionArgsValidationError( + 'It is not permitted to specify both "first" and "last" arguments simultaneously.', + ); + } + + if (last > 100 || last < 1) { + throw new ConnectionArgsValidationError('The "last" argument accepts a value between 1 and 100, inclusive.'); + } + + take = last; + skip = totalEdges > last ? totalEdges - last : 0; + } + + if (after != null) { + if (last != null) { + throw new ConnectionArgsValidationError( + 'It is not permitted to specify both "last" and "after" arguments simultaneously.', + ); + } + + skip = OffsetCursor.create(after).parameters.offset + 1; + } + + if (before != null) { + throw new ConnectionArgsValidationError('This connection does not support the "before" argument for pagination.'); + } + + return new OffsetCursorPaginator({ + take, + skip, + totalEdges, + }); + } +} diff --git a/src/cursor/index.ts b/src/cursor/index.ts new file mode 100644 index 00000000..919fa6bc --- /dev/null +++ b/src/cursor/index.ts @@ -0,0 +1,7 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './Cursor'; +export * from './OffsetCursorPaginator.spec'; +export * from './OffsetCursorPaginator'; diff --git a/src/error/ConnectionArgsValidationError.ts b/src/error/ConnectionArgsValidationError.ts new file mode 100644 index 00000000..6701dd59 --- /dev/null +++ b/src/error/ConnectionArgsValidationError.ts @@ -0,0 +1 @@ +export class ConnectionArgsValidationError extends Error {} diff --git a/src/error/CursorValidationError.ts b/src/error/CursorValidationError.ts new file mode 100644 index 00000000..e49148cc --- /dev/null +++ b/src/error/CursorValidationError.ts @@ -0,0 +1 @@ +export class CursorValidationError extends Error {} diff --git a/src/error/index.ts b/src/error/index.ts new file mode 100644 index 00000000..996b8378 --- /dev/null +++ b/src/error/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './ConnectionArgsValidationError'; +export * from './CursorValidationError'; diff --git a/src/index.ts b/src/index.ts index 6381c8a1..81da3baa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,6 @@ * @file Automatically generated by barrelsby. */ +export * from './cursor/index'; +export * from './error/index'; export * from './type/index'; From f54960ca62778a638c76efcacab1917f85530ebd Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Fri, 6 Mar 2020 13:06:51 +1300 Subject: [PATCH 3/5] Install @types/jest --- package-lock.json | 10 ++++++++++ package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index 4b035ec1..450a2aa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -864,6 +864,16 @@ "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "25.1.3", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-25.1.3.tgz", + "integrity": "sha512-jqargqzyJWgWAJCXX96LBGR/Ei7wQcZBvRv0PLEu9ZByMfcs23keUJrKv9FMR6YZf9YCbfqDqgmY+JUBsnqhrg==", + "dev": true, + "requires": { + "jest-diff": "^25.1.0", + "pretty-format": "^25.1.0" + } + }, "@types/joi": { "version": "14.3.4", "resolved": "https://registry.npmjs.org/@types/joi/-/joi-14.3.4.tgz", diff --git a/package.json b/package.json index 0251d917..eb6daa95 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@types/graphql-relay": "^0.4.11", + "@types/jest": "^25.1.3", "@types/joi": "^14.3.4", "@types/node": "12.x", "@typescript-eslint/eslint-plugin": "=2.21.0", From 50eb407d417dd3a1aabea23670af23bcb56725ef Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Fri, 6 Mar 2020 13:07:00 +1300 Subject: [PATCH 4/5] Add test for OffsetCursorPaginator --- src/cursor/OffsetCursorPaginator.spec.ts | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/cursor/OffsetCursorPaginator.spec.ts diff --git a/src/cursor/OffsetCursorPaginator.spec.ts b/src/cursor/OffsetCursorPaginator.spec.ts new file mode 100644 index 00000000..fd3dad65 --- /dev/null +++ b/src/cursor/OffsetCursorPaginator.spec.ts @@ -0,0 +1,54 @@ +import { OffsetCursor, OffsetCursorPaginator } from './OffsetCursorPaginator'; + +describe('OffsetCursorPaginator', () => { + test('PageInfo is correct for first page', () => { + const paginator = new OffsetCursorPaginator({ + take: 20, + skip: 0, + totalEdges: 50, + }); + const pageInfo = paginator.createPageInfo(20); + + expect(pageInfo.totalEdges).toBe(50); + expect(pageInfo.hasPreviousPage).toBe(false); + expect(pageInfo.hasNextPage).toBe(true); + expect(pageInfo.startCursor).toBeDefined(); + expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(0); + expect(pageInfo.endCursor).toBeDefined(); + expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(19); + }); + + test('PageInfo is correct for second page', () => { + const paginator = new OffsetCursorPaginator({ + take: 20, + skip: 20, + totalEdges: 50, + }); + const pageInfo = paginator.createPageInfo(20); + + expect(pageInfo.totalEdges).toBe(50); + expect(pageInfo.hasPreviousPage).toBe(true); + expect(pageInfo.hasNextPage).toBe(true); + expect(pageInfo.startCursor).toBeDefined(); + expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(20); + expect(pageInfo.endCursor).toBeDefined(); + expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(39); + }); + + test('PageInfo is correct for last page', () => { + const paginator = new OffsetCursorPaginator({ + take: 20, + skip: 40, + totalEdges: 50, + }); + const pageInfo = paginator.createPageInfo(10); + + expect(pageInfo.totalEdges).toBe(50); + expect(pageInfo.hasPreviousPage).toBe(true); + expect(pageInfo.hasNextPage).toBe(false); + expect(pageInfo.startCursor).toBeDefined(); + expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(40); + expect(pageInfo.endCursor).toBeDefined(); + expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(49); + }); +}); From fa1eb535453b9b0e4cb2acea6d3f27be3af87dff Mon Sep 17 00:00:00 2001 From: Simon Garner Date: Fri, 6 Mar 2020 13:11:11 +1300 Subject: [PATCH 5/5] Allow TS non-null (!) assertions --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index 9b76f1a7..570c284c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,6 +50,7 @@ module.exports = { 'no-useless-constructor': 'off', '@typescript-eslint/no-useless-constructor': 'off', '@typescript-eslint/no-parameter-properties': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/consistent-type-assertions': [ 'error', {