From d53a3081c03ae514336961faedc8e8a4d3e2157e Mon Sep 17 00:00:00 2001 From: ModZero Date: Wed, 9 Oct 2019 04:04:46 +0200 Subject: [PATCH] Tasks endpoint --- package.json | 1 + sql/tasks/count.sql | 1 + sql/tasks/hasNext.sql | 1 + sql/tasks/hasPrev.sql | 1 + sql/tasks/list.sql | 4 +- sql/tasks/listReverse.sql | 14 +++ src/api.ts | 127 --------------------- src/api/index.ts | 36 ++++++ src/api/resolvers.ts | 226 +++++++++++++++++++++++++++++++++++++ src/api/typeDefs.ts | 79 +++++++++++++ src/db/models.ts | 16 ++- src/db/repos/migrations.ts | 12 +- src/db/repos/tasks.ts | 45 +++++++- src/db/sql/index.ts | 6 +- tslint.json | 3 +- 15 files changed, 425 insertions(+), 147 deletions(-) create mode 100644 sql/tasks/count.sql create mode 100644 sql/tasks/hasNext.sql create mode 100644 sql/tasks/hasPrev.sql create mode 100644 sql/tasks/listReverse.sql delete mode 100644 src/api.ts create mode 100644 src/api/index.ts create mode 100644 src/api/resolvers.ts create mode 100644 src/api/typeDefs.ts diff --git a/package.json b/package.json index 5d0ebb6..ac75b7f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "express": "^4.17.1", "express-pino-logger": "^4.0.0", "graphql": "^14.5.7", + "graphql-tools": "^4.0.5", "helmet": "^3.21.1", "http-errors": "^1.7.3", "luxon": "^1.19.2", diff --git a/sql/tasks/count.sql b/sql/tasks/count.sql new file mode 100644 index 0000000..1d04883 --- /dev/null +++ b/sql/tasks/count.sql @@ -0,0 +1 @@ +SELECT count(*) c FROM "tasks" WHERE owner = $1; \ No newline at end of file diff --git a/sql/tasks/hasNext.sql b/sql/tasks/hasNext.sql new file mode 100644 index 0000000..dfaa571 --- /dev/null +++ b/sql/tasks/hasNext.sql @@ -0,0 +1 @@ +SELECT COUNT(*) > 0 r FROM "tasks" WHERE owner = $1 AND "id" > $2; \ No newline at end of file diff --git a/sql/tasks/hasPrev.sql b/sql/tasks/hasPrev.sql new file mode 100644 index 0000000..d6f7690 --- /dev/null +++ b/sql/tasks/hasPrev.sql @@ -0,0 +1 @@ +SELECT COUNT(*) > 0 r FROM "tasks" WHERE owner = $1 AND "id" < $2; \ No newline at end of file diff --git a/sql/tasks/list.sql b/sql/tasks/list.sql index 197b70d..8bb2ba8 100644 --- a/sql/tasks/list.sql +++ b/sql/tasks/list.sql @@ -9,6 +9,6 @@ SELECT FROM tasks WHERE - owner = $1 + owner = $1 AND ($2 IS NULL OR id > $2) AND ($3 IS NULL OR id < $3) ORDER BY created_at ASC -LIMIT $2 OFFSET $3; +LIMIT $4; diff --git a/sql/tasks/listReverse.sql b/sql/tasks/listReverse.sql new file mode 100644 index 0000000..2d1e01b --- /dev/null +++ b/sql/tasks/listReverse.sql @@ -0,0 +1,14 @@ +SELECT + id, + name, + notes, + schedule, + min_frequency, + max_frequency, + created_at +FROM + tasks +WHERE + owner = $1 AND ($2 IS NULL OR id > $2) AND ($3 IS NULL OR id < $3) +ORDER BY created_at DESC +LIMIT $4; diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index b3c4063..0000000 --- a/src/api.ts +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (C) 2019 ModZero -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as -// published by the Free Software Foundation, either version 3 of the -// License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -import { db } from "@kredens/db"; -import { User } from "@kredens/db/models"; -import { ApolloServer, AuthenticationError, gql } from "apollo-server-express"; -import { Kind } from "graphql/language"; -import { GraphQLScalarType, GraphQLScalarTypeConfig } from "graphql/type"; -import { DateTime } from "luxon"; - -const typeDefs = gql` - type Query { - "A simple type for getting started" - hello: String - migrations: [Migration]! - user(id: ID): User - } - - type Migration { - id: ID! - name: String! - applied_at: DateTime! - } - - type User { - id: ID! - email: String! - tasks: [Task]! - } - - type Task { - id: ID - name: String! - notes: String - schedule: ScheduleType! - min_frequency: Int - max_frequency: Int - created_at: DateTime! - } - - scalar DateTime - - enum ScheduleType { - ONCE - DAILY - WEEKLY - MONTHLY - YEARLY - } -`; - -const dateTimeConfig: GraphQLScalarTypeConfig = { - description: "Date custom scalar type", - name: "DateTime", - parseValue(value) { - return DateTime.fromISO(value as string); - }, - serialize(value) { - return (value as DateTime).toISO(); - }, - parseLiteral(ast) { - if (ast.kind === Kind.STRING) { - return DateTime.fromISO(ast.value); - } - - return null; - } -}; - -const resolvers = { - DateTime: new GraphQLScalarType(dateTimeConfig), - Query: { - hello: () => `Hello, world!`, - migrations: () => db.migrations.applied(), - user: async ( - parent: any, - { id }: { id: string }, - context: { user?: User } - ) => { - if (!context.user || context.user.id !== +id) { - throw new AuthenticationError( - "You cannot query users other than yourself" - ); - } - return db.users.details(+id).then(user => user.orNull()); - } - }, - User: { - tasks: (user: User) => - db.tasks.list(user.id).then(tasks => - tasks.map(t => ({ - ...t, - schedule: t.schedule.toUpperCase() - })) - ) - } -}; - -export function server() { - return new ApolloServer({ - context: async req => { - const user = req.req.user; - - if (!user) { - throw new AuthenticationError("you must be logged in"); - } - - return { - user - }; - }, - resolvers, - typeDefs - }); -} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..5678c9a --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,36 @@ +// Copyright (C) 2019 ModZero +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import resolvers from "@kredens/api/resolvers"; +import typeDefs from "@kredens/api/typeDefs"; +import { ApolloServer, AuthenticationError } from "apollo-server-express"; + +export function server() { + return new ApolloServer({ + context: async req => { + const user = req.req.user; + + if (!user) { + throw new AuthenticationError("you must be logged in"); + } + + return { + user + }; + }, + resolvers, + typeDefs + }); +} diff --git a/src/api/resolvers.ts b/src/api/resolvers.ts new file mode 100644 index 0000000..c34f0e8 --- /dev/null +++ b/src/api/resolvers.ts @@ -0,0 +1,226 @@ +// Copyright (C) 2019 ModZero +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { db } from "@kredens/db"; +import * as models from "@kredens/db/models"; +import { IResolvers } from "graphql-tools"; +import { Kind } from "graphql/language"; +import { GraphQLScalarType, GraphQLScalarTypeConfig } from "graphql/type"; +import { DateTime } from "luxon"; +import { Maybe } from "monet"; + +const dateTimeConfig: GraphQLScalarTypeConfig = { + name: "DateTime", + description: "Date custom scalar type", + serialize(value) { + return (value as DateTime).toISO(); + }, + parseValue(value) { + return DateTime.fromISO(value as string); + }, + parseLiteral(ast) { + if (ast.kind === Kind.STRING) { + return DateTime.fromISO(ast.value); + } + + return null; + } +}; + +interface Context { + user?: models.User; +} + +interface Node { + ID: string; +} + +interface User extends Node { + email?: string; + tasks?: Connection; +} + +type ScheduleType = "ONCE" | "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"; + +interface Task extends Node { + name: string; + notes?: string; + schedule: ScheduleType; + minFrequency?: number; + maxFrequency?: number; + createdAt: DateTime; +} + +interface PaginationArguments { + after?: string; + first?: number; + before?: string; + last?: number; +} + +interface PageInfo { + hasNextPage: boolean; + hasPrevPage: boolean; + startCursor?: string; + endCursor?: string; +} + +interface Edge { + cursor: string; + node?: T; +} + +interface Connection { + edges?: Array>; + pageInfo: PageInfo; +} + +enum NodeKind { + User = "USER", + Task = "TASK" +} + +const nodeKindFrom = (kind: string) => { + const keys = Object.keys(NodeKind).filter(x => NodeKind[x] === kind); + return keys.length > 0 + ? Maybe.some(NodeKind[keys[0]]) + : Maybe.none(); +}; + +interface ID { + kind: NodeKind; + id: number; +} + +const getId = (id: string) => { + const [kindStr, idStr] = Buffer.from(id, "base64") + .toString() + .split(":"); + const kind = nodeKindFrom(kindStr); + + return kind.flatMap(k => { + const actualId = parseInt(idStr, 10); + + return isNaN(actualId) + ? Maybe.none() + : Maybe.some({ id: actualId, kind: k }); + }); +}; + +const getIdOfKind = (id: string, kind: NodeKind) => + getId(id).filter(i => i.kind === kind); + +const buildId = (kind: NodeKind, id: number) => + Buffer.from(`${kind}:${id}`).toString("base64"); + +const scheduleTypeMap: { [id: string]: ScheduleType } = { + [models.ScheduleType.Once]: "ONCE", + [models.ScheduleType.Daily]: "DAILY", + [models.ScheduleType.Weekly]: "WEEKLY", + [models.ScheduleType.Monthly]: "MONTHLY", + [models.ScheduleType.Yearly]: "YEARLY" +}; + +async function userTasks( + user: { id: string }, + args: PaginationArguments +): Promise> { + return db.task>(async t => { + const after = + args.after && + Maybe.some(args.before) + .flatMap(id => getIdOfKind(id, NodeKind.Task)) + .some().id; + const before = + args.before && + Maybe.fromFalsy(args.before) + .flatMap(id => getIdOfKind(id, NodeKind.Task)) + .some().id; + const userId = getId(user.id).some().id; + + let rows = + args.first || !(args.first || args.last) + ? await t.tasks.list(userId, args.first, after, before) + : (await t.tasks.listReverse( + userId, + args.last, + after, + before + )).reverse(); + + if (args.first && args.last) { + rows = rows.slice(-1 * args.last); + } + + return { + edges: rows.map(row => ({ + cursor: buildId(NodeKind.Task, row.id), + node: { + ID: buildId(NodeKind.Task, row.id), + createdAt: row.createdAt, + maxFrequency: row.maxFrequency, + minFrequency: row.minFrequency, + name: row.name, + notes: row.notes, + schedule: scheduleTypeMap[row.schedule] + } + })), + pageInfo: { + hasNextPage: + rows.length > 0 ? await t.tasks.hasNext(userId, rows[0].id) : false, + hasPrevPage: + rows.length > 0 + ? await t.tasks.hasPrev(userId, rows[rows.length - 1].id) + : false, + startCursor: + rows.length > 0 ? buildId(NodeKind.Task, rows[0].id) : null, + endCursor: + rows.length > 0 + ? buildId(NodeKind.Task, rows[rows.length - 1].id) + : null + } + }; + }); +} + +const resolvers: IResolvers = { + DateTime: new GraphQLScalarType(dateTimeConfig), + Query: { + hello: () => `Hello, world!`, + migrations: () => db.migrations.applied(), + user: async (parent: any, { id }: { id?: string }, context) => + Maybe.fromFalsy(id) + .orElse(Maybe.some(buildId(NodeKind.User, context.user.id))) + .flatMap(i => getId(i)) + .filter(i => i.kind === NodeKind.User) + .filter(i => i.id === context.user.id) + .map(i => + db.users.details(i.id).then(user => + user + .map(u => ({ + email: u.email, + id: buildId(NodeKind.User, u.id) + })) + .orNull() + ) + ) + .orNull() + }, + User: { + tasks: userTasks + } +}; + +export default resolvers; diff --git a/src/api/typeDefs.ts b/src/api/typeDefs.ts new file mode 100644 index 0000000..3522ced --- /dev/null +++ b/src/api/typeDefs.ts @@ -0,0 +1,79 @@ +// Copyright (C) 2019 ModZero +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { gql } from "apollo-server-express"; + +export default gql` + type Query { + "A simple type for getting started" + node(id: ID!): Node + hello: String + migrations: [Migration]! + user(id: ID): User + } + + interface Node { + id: ID! + } + + type Migration implements Node { + id: ID! + name: String! + appliedAt: DateTime! + } + + type User implements Node { + id: ID! + email: String! + tasks(after: String, first: Int, before: String, last: Int): TaskConnection + } + + type Task implements Node { + id: ID! + name: String! + notes: String + schedule: ScheduleType! + minFrequency: Int + maxFrequency: Int + createdAt: DateTime! + } + + type TaskConnection { + edges: [TaskEdge] + pageInfo: PageInfo! + } + + type TaskEdge { + cursor: String! + node: Task + } + + type PageInfo { + hasNextPage: Boolean! + hasPrevPage: Boolean! + startCursor: String + endCursor: String + } + + scalar DateTime + + enum ScheduleType { + ONCE + DAILY + WEEKLY + MONTHLY + YEARLY + } +`; diff --git a/src/db/models.ts b/src/db/models.ts index 5022164..c5335fc 100644 --- a/src/db/models.ts +++ b/src/db/models.ts @@ -18,7 +18,7 @@ import { DateTime } from "luxon"; export interface Migration { id: number; name: string; - applied_at: DateTime; + appliedAt: DateTime; } export interface User { @@ -26,7 +26,13 @@ export interface User { email: string; } -export type ScheduleType = "once" | "daily" | "weekly" | "monthly" | "yearly"; +export enum ScheduleType { + Once = "once", + Daily = "daily", + Weekly = "weekly", + Monthly = "monthly", + Yearly = "yearly" +} export interface Task { id: number; @@ -34,9 +40,9 @@ export interface Task { name: string; notes?: string; schedule: ScheduleType; - min_frequency?: number; - max_frequency?: number; - created_at: DateTime; + minFrequency?: number; + maxFrequency?: number; + createdAt: DateTime; } export interface Session { diff --git a/src/db/repos/migrations.ts b/src/db/repos/migrations.ts index aaa77c8..58e9c5f 100644 --- a/src/db/repos/migrations.ts +++ b/src/db/repos/migrations.ts @@ -48,12 +48,10 @@ export class MigrationRepository { } public async applied(): Promise { - return this.db.map(sql.applied, [], row => { - return { - applied_at: row.applied_at as DateTime, - id: +row.id, - name: row.name - }; - }); + return this.db.map(sql.applied, [], row => ({ + appliedAt: row.applied_at as DateTime, + id: +row.id, + name: row.name + })); } } diff --git a/src/db/repos/tasks.ts b/src/db/repos/tasks.ts index 2c51895..c0d8d5b 100644 --- a/src/db/repos/tasks.ts +++ b/src/db/repos/tasks.ts @@ -17,6 +17,19 @@ import { ScheduleType, Task } from "@kredens/db/models"; import { tasks as sql } from "@kredens/db/sql"; import { IDatabase, IMain } from "pg-promise"; +function rowToTask(row: any): Task { + return { + createdAt: row.created_at, + id: +row.id, + maxFrequency: +row.maxFrequency, + minFrequency: +row.minFrequency, + name: row.name, + notes: row.notes, + owner: +row.owner, + schedule: row.schedule as ScheduleType + }; +} + export class TaskRepository { private db: IDatabase; @@ -24,9 +37,33 @@ export class TaskRepository { this.db = db; } - public async list(owner: number): Promise { - return this.db - .manyOrNone(sql.list, [owner, 10, 0]) - .then(rows => (rows ? rows : [])); + public async list( + owner: number, + limit?: number, + after?: number, + before?: number + ): Promise { + return this.db.map(sql.list, [owner, after, before, limit || 10], row => + rowToTask(row) + ); + } + + public async listReverse( + owner: number, + limit?: number, + after?: number, + before?: number + ): Promise { + return this.db.map(sql.list, [owner, after, before, limit || 10], row => + rowToTask(row) + ); + } + + public async hasNext(owner: number, after: number): Promise { + return this.db.one(sql.hasNext, [owner, after]).then(row => row.r); + } + + public async hasPrev(owner: number, before: number): Promise { + return this.db.one(sql.hasPrev, [owner, before]).then(row => row.r); } } diff --git a/src/db/sql/index.ts b/src/db/sql/index.ts index e334f74..7dcdd70 100644 --- a/src/db/sql/index.ts +++ b/src/db/sql/index.ts @@ -42,7 +42,11 @@ const users = { }; const tasks = { - list: sql("tasks/list.sql") + count: sql("tasks/count.sql"), + hasNext: sql("tasks/hasNext.sql"), + hasPrev: sql("tasks/hasPrev.sql"), + list: sql("tasks/list.sql"), + listReverse: sql("tasks/listReverse.sql") }; const sessions = { diff --git a/tslint.json b/tslint.json index 91cb305..aacdda3 100644 --- a/tslint.json +++ b/tslint.json @@ -11,7 +11,8 @@ "vue-loader/lib/plugin", "@kredens" ], - "no-implicit-dependencies": [true, ["@kredens"]] + "no-implicit-dependencies": [true, ["@kredens"]], + "object-literal-sort-keys": [true, "match-declaration-order-only", "shorthand-first"] }, "rulesDirectory": [] }