Skip to content

Commit

Permalink
Merge pull request #1 from madscience/types-and-pagination
Browse files Browse the repository at this point in the history
Create Relay types and OffsetCursorPaginator
  • Loading branch information
sgarner authored Mar 6, 2020
2 parents d3d8a30 + fa1eb53 commit c48bb84
Show file tree
Hide file tree
Showing 17 changed files with 389 additions and 0 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
7 changes: 7 additions & 0 deletions barrelsby.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"directory": "src/",
"location": "all",
"structure": "flat",
"singleQuotes": true,
"delete": true
}
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 43 additions & 0 deletions src/cursor/Cursor.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
54 changes: 54 additions & 0 deletions src/cursor/OffsetCursorPaginator.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
115 changes: 115 additions & 0 deletions src/cursor/OffsetCursorPaginator.ts
Original file line number Diff line number Diff line change
@@ -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<OffsetCursorPaginator, 'take' | 'skip' | 'totalEdges'>) {
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,
});
}
}
7 changes: 7 additions & 0 deletions src/cursor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @file Automatically generated by barrelsby.
*/

export * from './Cursor';
export * from './OffsetCursorPaginator.spec';
export * from './OffsetCursorPaginator';
1 change: 1 addition & 0 deletions src/error/ConnectionArgsValidationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class ConnectionArgsValidationError extends Error {}
1 change: 1 addition & 0 deletions src/error/CursorValidationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export class CursorValidationError extends Error {}
6 changes: 6 additions & 0 deletions src/error/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @file Automatically generated by barrelsby.
*/

export * from './ConnectionArgsValidationError';
export * from './CursorValidationError';
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* @file Automatically generated by barrelsby.
*/

export * from './cursor/index';
export * from './error/index';
export * from './type/index';
39 changes: 39 additions & 0 deletions src/type/Connection.ts
Original file line number Diff line number Diff line change
@@ -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<TNode> extends Relay.Connection<TNode> {
pageInfo: PageInfo;
edges: EdgeInterface<TNode>[];
}

export function createConnectionType<TNode>(
TEdgeClass: new (fields?: Partial<EdgeInterface<TNode>>) => EdgeInterface<TNode>,
): new (fields?: Partial<ConnectionInterface<TNode>>) => ConnectionInterface<TNode> {
// 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<TNode> {
@GQL.Field(_type => PageInfo)
public pageInfo: PageInfo;

@GQL.Field(_type => [TEdgeClass])
public edges: EdgeInterface<TNode>[];

constructor(fields?: Partial<ConnectionInterface<TNode>>) {
if (fields != null) {
if (fields.pageInfo != null) {
this.pageInfo = fields.pageInfo;
}

if (fields.edges != null) {
this.edges = fields.edges;
}
}
}
}

return Connection;
}
28 changes: 28 additions & 0 deletions src/type/ConnectionArgs.ts
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 41 additions & 0 deletions src/type/Edge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as Relay from 'graphql-relay';
import * as GQL from 'type-graphql';

export interface EdgeInterface<TNode> extends Relay.Edge<TNode> {
node: TNode;
cursor: string;
}

export function createEdgeType<TNode>(
TNodeClass: new () => TNode,
): new (fields?: Partial<EdgeInterface<TNode>>) => EdgeInterface<TNode> {
// 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<TNode> {
@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<EdgeInterface<TNode>>) {
if (fields != null) {
if (fields.node != null) {
this.node = fields.node;
}

if (fields.cursor != null) {
this.cursor = fields.cursor;
}
}
}
}

return Edge;
}
Loading

0 comments on commit c48bb84

Please sign in to comment.