Skip to content

Commit c48bb84

Browse files
authored
Merge pull request #1 from madscience/types-and-pagination
Create Relay types and OffsetCursorPaginator
2 parents d3d8a30 + fa1eb53 commit c48bb84

17 files changed

+389
-0
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ module.exports = {
5050
'no-useless-constructor': 'off',
5151
'@typescript-eslint/no-useless-constructor': 'off',
5252
'@typescript-eslint/no-parameter-properties': 'off',
53+
'@typescript-eslint/no-non-null-assertion': 'off',
5354
'@typescript-eslint/consistent-type-assertions': [
5455
'error',
5556
{

barrelsby.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"directory": "src/",
3+
"location": "all",
4+
"structure": "flat",
5+
"singleQuotes": true,
6+
"delete": true
7+
}

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"devDependencies": {
3030
"@types/graphql-relay": "^0.4.11",
31+
"@types/jest": "^25.1.3",
3132
"@types/joi": "^14.3.4",
3233
"@types/node": "12.x",
3334
"@typescript-eslint/eslint-plugin": "=2.21.0",

src/cursor/Cursor.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as querystring from 'querystring';
2+
import Joi from 'joi';
3+
4+
export class Cursor {
5+
constructor(public readonly parameters: querystring.ParsedUrlQueryInput) {}
6+
7+
public toString(): string {
8+
return querystring.stringify(this.parameters, '&', '=');
9+
}
10+
11+
public encode(): string {
12+
return Buffer.from(this.toString()).toString('base64');
13+
}
14+
15+
public static decode(encodedString: string): querystring.ParsedUrlQuery {
16+
// opaque cursors are base64 encoded, decode it first
17+
const decodedString = Buffer.from(encodedString, 'base64').toString();
18+
19+
// cursor string is URL encoded, parse it into a map of parameters
20+
return querystring.parse(decodedString, '&', '=', {
21+
maxKeys: 20,
22+
});
23+
}
24+
25+
public static create(encodedString: string, schema: Joi.ObjectSchema): Cursor {
26+
// opaque cursors are base64 encoded, decode it first
27+
const decodedString = Buffer.from(encodedString, 'base64').toString();
28+
29+
// cursor string is URL encoded, parse it into a map of parameters
30+
const parameters = querystring.parse(decodedString, '&', '=', {
31+
maxKeys: 20,
32+
});
33+
34+
// validate the cursor parameters match the schema we expect, this also converts data types
35+
const { error, value: validatedParameters } = Joi.validate(parameters, schema);
36+
37+
if (error != null) {
38+
throw error;
39+
}
40+
41+
return new Cursor(validatedParameters);
42+
}
43+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { OffsetCursor, OffsetCursorPaginator } from './OffsetCursorPaginator';
2+
3+
describe('OffsetCursorPaginator', () => {
4+
test('PageInfo is correct for first page', () => {
5+
const paginator = new OffsetCursorPaginator({
6+
take: 20,
7+
skip: 0,
8+
totalEdges: 50,
9+
});
10+
const pageInfo = paginator.createPageInfo(20);
11+
12+
expect(pageInfo.totalEdges).toBe(50);
13+
expect(pageInfo.hasPreviousPage).toBe(false);
14+
expect(pageInfo.hasNextPage).toBe(true);
15+
expect(pageInfo.startCursor).toBeDefined();
16+
expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(0);
17+
expect(pageInfo.endCursor).toBeDefined();
18+
expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(19);
19+
});
20+
21+
test('PageInfo is correct for second page', () => {
22+
const paginator = new OffsetCursorPaginator({
23+
take: 20,
24+
skip: 20,
25+
totalEdges: 50,
26+
});
27+
const pageInfo = paginator.createPageInfo(20);
28+
29+
expect(pageInfo.totalEdges).toBe(50);
30+
expect(pageInfo.hasPreviousPage).toBe(true);
31+
expect(pageInfo.hasNextPage).toBe(true);
32+
expect(pageInfo.startCursor).toBeDefined();
33+
expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(20);
34+
expect(pageInfo.endCursor).toBeDefined();
35+
expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(39);
36+
});
37+
38+
test('PageInfo is correct for last page', () => {
39+
const paginator = new OffsetCursorPaginator({
40+
take: 20,
41+
skip: 40,
42+
totalEdges: 50,
43+
});
44+
const pageInfo = paginator.createPageInfo(10);
45+
46+
expect(pageInfo.totalEdges).toBe(50);
47+
expect(pageInfo.hasPreviousPage).toBe(true);
48+
expect(pageInfo.hasNextPage).toBe(false);
49+
expect(pageInfo.startCursor).toBeDefined();
50+
expect(OffsetCursor.create(pageInfo.startCursor!).parameters.offset).toStrictEqual(40);
51+
expect(pageInfo.endCursor).toBeDefined();
52+
expect(OffsetCursor.create(pageInfo.endCursor!).parameters.offset).toStrictEqual(49);
53+
});
54+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import Joi from 'joi';
2+
import { ConnectionArgs, PageInfo } from '../type';
3+
import { Cursor } from './Cursor';
4+
import { ConnectionArgsValidationError, CursorValidationError } from '../error';
5+
6+
export class OffsetCursor extends Cursor {
7+
public parameters: {
8+
offset: number;
9+
};
10+
11+
public static create(encodedString: string): OffsetCursor {
12+
const parameters = Cursor.decode(encodedString);
13+
14+
// validate the cursor parameters match the schema we expect, this also converts data types
15+
const { error, value: validatedParameters } = Joi.validate(
16+
parameters,
17+
Joi.object({
18+
offset: Joi.number()
19+
.integer()
20+
.min(0)
21+
.empty('')
22+
.required(),
23+
}).unknown(false),
24+
);
25+
26+
if (error != null) {
27+
const errorMessages =
28+
error.details != null ? error.details.map(detail => `- ${detail.message}`).join('\n') : `- ${error.message}`;
29+
30+
throw new CursorValidationError(
31+
`A provided cursor value is not valid. The following problems were found:\n\n${errorMessages}`,
32+
);
33+
}
34+
35+
return new OffsetCursor(validatedParameters);
36+
}
37+
}
38+
39+
export class OffsetCursorPaginator {
40+
public take: number = 20;
41+
public skip: number = 0;
42+
public totalEdges: number = 0;
43+
44+
constructor({ take, skip, totalEdges }: Pick<OffsetCursorPaginator, 'take' | 'skip' | 'totalEdges'>) {
45+
this.take = take;
46+
this.skip = skip;
47+
this.totalEdges = totalEdges;
48+
}
49+
50+
public createPageInfo(edgesInPage: number): PageInfo {
51+
return {
52+
startCursor: edgesInPage > 0 ? this.createCursor(0).encode() : null,
53+
endCursor: edgesInPage > 0 ? this.createCursor(edgesInPage - 1).encode() : null,
54+
hasNextPage: this.skip + edgesInPage < this.totalEdges,
55+
hasPreviousPage: this.skip > 0,
56+
totalEdges: this.totalEdges,
57+
};
58+
}
59+
60+
public createCursor(index: number): OffsetCursor {
61+
return new OffsetCursor({ offset: this.skip + index });
62+
}
63+
64+
public static createFromConnectionArgs(
65+
{ first, last, before, after }: ConnectionArgs,
66+
totalEdges: number,
67+
): OffsetCursorPaginator {
68+
let take: number = 20;
69+
let skip: number = 0;
70+
71+
if (first != null) {
72+
if (first > 100 || first < 1) {
73+
throw new ConnectionArgsValidationError('The "first" argument accepts a value between 1 and 100, inclusive.');
74+
}
75+
76+
take = first;
77+
skip = 0;
78+
}
79+
80+
if (last != null) {
81+
if (first != null) {
82+
throw new ConnectionArgsValidationError(
83+
'It is not permitted to specify both "first" and "last" arguments simultaneously.',
84+
);
85+
}
86+
87+
if (last > 100 || last < 1) {
88+
throw new ConnectionArgsValidationError('The "last" argument accepts a value between 1 and 100, inclusive.');
89+
}
90+
91+
take = last;
92+
skip = totalEdges > last ? totalEdges - last : 0;
93+
}
94+
95+
if (after != null) {
96+
if (last != null) {
97+
throw new ConnectionArgsValidationError(
98+
'It is not permitted to specify both "last" and "after" arguments simultaneously.',
99+
);
100+
}
101+
102+
skip = OffsetCursor.create(after).parameters.offset + 1;
103+
}
104+
105+
if (before != null) {
106+
throw new ConnectionArgsValidationError('This connection does not support the "before" argument for pagination.');
107+
}
108+
109+
return new OffsetCursorPaginator({
110+
take,
111+
skip,
112+
totalEdges,
113+
});
114+
}
115+
}

src/cursor/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* @file Automatically generated by barrelsby.
3+
*/
4+
5+
export * from './Cursor';
6+
export * from './OffsetCursorPaginator.spec';
7+
export * from './OffsetCursorPaginator';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class ConnectionArgsValidationError extends Error {}

src/error/CursorValidationError.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class CursorValidationError extends Error {}

0 commit comments

Comments
 (0)