-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from madscience/types-and-pagination
Create Relay types and OffsetCursorPaginator
- Loading branch information
Showing
17 changed files
with
389 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"directory": "src/", | ||
"location": "all", | ||
"structure": "flat", | ||
"singleQuotes": true, | ||
"delete": true | ||
} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export class ConnectionArgsValidationError extends Error {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export class CursorValidationError extends Error {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.