Skip to content

Commit

Permalink
Add GraphQL API example (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
koistya authored May 24, 2021
1 parent 2bca154 commit c6741d2
Show file tree
Hide file tree
Showing 24 changed files with 701 additions and 32 deletions.
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
Node.js API Starter Kit is a project template for building Node.js backend applications
optimized for serverless infrastructure such as [Google Cloud Functions](https://cloud.google.com/functions),
[AWS Lambda](https://aws.amazon.com/lambda/), [Azure Functions](https://azure.microsoft.com/services/functions/), etc.
Use it as an API server for your front-end app, complimenting it with any API library or framework
of your choice, such as [GraphQL.js](https://www.npmjs.com/package/graphql).
Use it as an API server for your front-end app.

## Features

- Database first design; auto-generated strongly typed data models (TypeScript)
- Authentication and authorization using OAuth 2.0 providers (Google, Facebook, GitHub, etc.)
- Stateless sessions implemented with JWT tokens and a session cookie (compatible with SSR)
- GraphQL API example, implemented using the code-first development approach
- Database schema migration, seeds, and REPL shell tooling
- Transactional emails using Handlebars templates and instant email previews
- Structured logs and error reporting to Google StackDriver
Expand All @@ -25,6 +25,8 @@ of your choice, such as [GraphQL.js](https://www.npmjs.com/package/graphql).
- Rebuilds and restarts the app on changes when running locally
- Pre-configured for `local`, `dev`, `test`, and `prod` environments

![](https://files.tarkus.me/graphql-api.png)

---

This project was bootstrapped with [Node.js API Starter Kit](https://github.com/kriasoft/node-starter-kit).
Expand All @@ -36,16 +38,16 @@ Be sure to join our [Discord channel](https://discord.com/invite/GrqQaSnvmr) for
[TypeScript](https://www.typescriptlang.org/), [Babel](https://babeljs.io/),
[Rollup](https://rollupjs.org/), [ESLint](https://eslint.org/),
[Prettier](https://prettier.io/), [Jest](https://jestjs.io/)
- [PostgreSQL](https://www.postgresql.org/), [Knex](https://knesjs.org/),
[Express](https://expressjs.com/), [Nodemailer](https://nodemailer.com/),
[Email Templates](https://email-templates.js.org/), [Handlebars](https://handlebarsjs.com/),
[Simple OAuth2](https://github.com/lelylan/simple-oauth2)
- [PostgreSQL](https://www.postgresql.org/), [GraphQL.js](https://github.com/graphql/graphql-js),
[Knex](https://knesjs.org/), [Express](https://expressjs.com/),
[Nodemailer](https://nodemailer.com/),[Email Templates](https://email-templates.js.org/),
[Handlebars](https://handlebarsjs.com/), [Simple OAuth2](https://github.com/lelylan/simple-oauth2)

## Directory Structure

`├──`[`.build`](.build) — Compiled and bundled output (per Cloud Function)<br>
`├──`[`.vscode`](.vscode) — VSCode settings including code snippets, recommended extensions etc.<br>
`├──`[`api`](./api) — Cloud Function for handling API requests<br>
`├──`[`api`](./api) — Cloud Function for handling API requests using [GraphQL.js](https://github.com/graphql/graphql-js)<br>
`├──`[`auth`](./auth) — Authentication and session middleware<br>
`├──`[`core`](./core) — Common application modules (email, logging, etc.)<br>
`├──`[`db`](./db) — Database client for PostgreSQL using [Knex](https://knexjs.org/)<br>
Expand Down
94 changes: 94 additions & 0 deletions api/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

import DataLoader from "dataloader";
import { Request } from "express";
import { Forbidden, Unauthorized } from "http-errors";
import db, { Identity, User } from "../db";
import { mapTo, mapToMany } from "./utils";

/**
* GraphQL execution context.
* @see https://graphql.org/learn/execution/
*/
export class Context {
private readonly req: Request;

constructor(req: Request) {
this.req = req;

// Add the currently logged in user object to the cache
if (req.user) {
this.userById.prime(req.user.id, req.user);
if (req.user.username) {
this.userByUsername.prime(req.user.username, req.user);
}
}
}

/*
* Authentication and authorization
* ------------------------------------------------------------------------ */

get user(): User | null {
return this.req.user;
}

signIn(user: User | null | undefined): Promise<User | null> {
return this.req.signIn(user);
}

signOut(): void {
this.req.signOut();
}

ensureAuthorized(check?: (user: User) => boolean): void {
if (!this.req.user) {
throw new Unauthorized();
}

if (check && !check(this.req.user)) {
throw new Forbidden();
}
}

/*
* Data loaders
* ------------------------------------------------------------------------ */

userById = new DataLoader<string, User | null>((keys) =>
db
.table<User>("user")
.whereIn("id", keys)
.select()
.then((rows) =>
rows.map((x) => {
if (x.username) this.userByUsername.prime(x.username, x);
return x;
})
)
.then((rows) => mapTo(rows, keys, (x) => x.id))
);

userByUsername = new DataLoader<string, User | null>((keys) =>
db
.table<User>("user")
.whereIn("username", keys)
.select()
.then((rows) =>
rows.map((x) => {
this.userById.prime(x.id, x);
return x;
})
)
.then((rows) => mapTo(rows, keys, (x) => x.username))
);

identitiesByUserId = new DataLoader<string, Identity[]>((keys) =>
db
.table<Identity>("identity")
.whereIn("user_id", keys)
.select()
.then((rows) => mapToMany(rows, keys, (x) => x.user_id))
);
}
50 changes: 50 additions & 0 deletions api/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

import type { Request } from "express";
import { graphqlHTTP } from "express-graphql";
import { formatError, GraphQLObjectType, GraphQLSchema } from "graphql";
import { reportError } from "../core";
import env from "../env";
import { Context } from "./context";
import * as mutations from "./mutations";
import * as queries from "./queries";
import { nodeField, nodesField } from "./types/node";
import { ValidationError } from "./utils";

export const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: "Root",
description: "The top-level API",

fields: {
node: nodeField,
nodes: nodesField,
...queries,
},
}),

mutation: new GraphQLObjectType({
name: "Mutation",
fields: mutations,
}),
});

/**
* GraphQL middleware for Express.js
*/
export const graphql = graphqlHTTP((req, res, params) => ({
schema,
context: new Context(req as Request),
graphiql: env.APP_ENV !== "prod",
pretty: !env.isProduction,
customFormatErrorFn: (err) => {
if (err.originalError instanceof ValidationError) {
return { ...formatError(err), errors: err.originalError.errors };
}

reportError(err.originalError || err, req as Request, params);
console.error(err.originalError || err);
return formatError(err);
},
}));
8 changes: 4 additions & 4 deletions api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import express from "express";
import { NotFound } from "http-errors";
import { auth } from "../auth";
import { handleError } from "./errors";
import { graphql } from "./graphql";
import { withViews } from "./views";

export const api = withViews(express());
Expand All @@ -16,14 +17,13 @@ api.disable("x-powered-by");
// OAuth 2.0 authentication endpoints and user sessions
api.use(auth);

// GraphQL API middleware
api.use("/api", graphql);

api.get("/", (req, res) => {
res.render("home");
});

api.get("/api/", function (req, res) {
res.send(`Hello from API!`);
});

api.get("/favicon.ico", function (req, res) {
res.redirect("https://nodejs.org/static/images/favicons/favicon.ico");
});
Expand Down
14 changes: 14 additions & 0 deletions api/mutations/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

import { GraphQLFieldConfig, GraphQLString } from "graphql";
import { Context } from "../context";

export const signOut: GraphQLFieldConfig<unknown, Context> = {
description: "Clears authentication session.",
type: GraphQLString,

resolve(self, args, ctx) {
ctx.signOut();
},
};
4 changes: 4 additions & 0 deletions api/mutations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

export * from "./auth";
18 changes: 18 additions & 0 deletions api/queries/fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

import { GraphQLFieldConfig, GraphQLInt, GraphQLNonNull } from "graphql";
import type { Knex } from "knex";
import { Context } from "../context";

export const countField: GraphQLFieldConfig<
{ query: Knex.QueryBuilder },
Context
> = {
type: new GraphQLNonNull(GraphQLInt),

async resolve(self) {
const rows = await self.query.count();
return rows[0].count;
},
};
6 changes: 6 additions & 0 deletions api/queries/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

export * from "./me";
export * from "./user";
export * from "./users";
16 changes: 16 additions & 0 deletions api/queries/me.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

import type { GraphQLFieldConfig } from "graphql";
import type { User } from "../../db";
import type { Context } from "../context";
import { UserType } from "../types";

export const me: GraphQLFieldConfig<User, Context> = {
description: "The authenticated user.",
type: UserType,

resolve(self, args, ctx) {
return ctx.user;
},
};
33 changes: 33 additions & 0 deletions api/queries/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

import { GraphQLFieldConfig, GraphQLString } from "graphql";
import type { User } from "../../db";
import db from "../../db";
import type { Context } from "../context";
import { UserType } from "../types";

export const user: GraphQLFieldConfig<User, Context> = {
description: "Find user by username or email.",
type: UserType,

args: {
username: { type: GraphQLString },
email: { type: GraphQLString },
},

resolve(self, args, ctx) {
const query = db.table<User>("user");

if (args.username) {
query.where("username", "=", args.username);
} else if (args.email) {
ctx.ensureAuthorized();
query.where("email", "=", args.email);
} else {
throw new Error("The username argument is required.");
}

return query.first();
},
};
51 changes: 51 additions & 0 deletions api/queries/users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { GraphQLFieldConfig } from "graphql";
import {
connectionDefinitions,
connectionFromArraySlice,
cursorToOffset,
forwardConnectionArgs,
} from "graphql-relay";
import db, { User } from "../../db";
import { Context } from "../context";
import { UserType } from "../types";
import { countField } from "./fields";

export const users: GraphQLFieldConfig<unknown, Context> = {
type: connectionDefinitions({
name: "User",
nodeType: UserType,
connectionFields: { totalCount: countField },
}).connectionType,

args: forwardConnectionArgs,

async resolve(root, args, ctx) {
// Only admins are allowed to fetch the list of user accounts.
ctx.ensureAuthorized((user) => user.admin);

const query = db.table<User>("user");

const limit = args.first === undefined ? 50 : args.first;
const offset = args.after ? cursorToOffset(args.after) + 1 : 0;

const data = await query
.clone()
.limit(limit)
.offset(offset)
.orderBy("created_at", "desc")
.select();

data.forEach((x) => {
ctx.userById.prime(x.id, x);
if (x.username) ctx.userByUsername.prime(x.username, x);
});

return {
...connectionFromArraySlice(data, args, {
sliceStart: offset,
arrayLength: offset + data.length,
}),
query,
};
},
};
12 changes: 12 additions & 0 deletions api/types/enums.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* SPDX-FileCopyrightText: 2016-present Kriasoft <[email protected]> */
/* SPDX-License-Identifier: MIT */

import { GraphQLEnumType } from "graphql";
import { mapValues } from "lodash";
import { IdentityProvider } from "../../db";

export const IdentityProviderType = new GraphQLEnumType({
name: "IdentityProvider",
description: "OAuth identity provider.",
values: mapValues(IdentityProvider, (value) => ({ value })),
});
Loading

0 comments on commit c6741d2

Please sign in to comment.