diff --git a/DESIGN.md b/DESIGN.md index 39e8e3c6f..4bd09913c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,19 +1,23 @@ -# HTTP Routes +# Design -## Stream API +## HTTP Routes -/api/tenant/:tenantID/graphql -/api/tenant/:tenantID/auth +### Stream API -## Tenant Management API +/api/tenant/graphql +/api/tenant/auth -/api/graphql -/api/auth +### Tenant Management API -# Folder structure +/api/management/graphql +/api/management/auth +## Folder structure + +```text /graph/tenant <-- tenant's api (comments, assets, ...) /graph/management <-- tenant management api +``` 1. No tenants 2. Create a tenant <-- consuming the TMA diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index bb0966906..6766cced7 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -1,78 +1,86 @@ import express, { Express, Router } from 'express'; import { Db } from 'mongodb'; +import http from 'http'; import { Config } from 'talk-server/config'; import { create } from 'talk-server/services/mongodb'; +import tenantGraphMiddleware from 'talk-server/graph/tenant/middleware'; +import managementGraphMiddleware from 'talk-server/graph/management/middleware'; import serveStatic from './middleware/serveStatic'; - import playground from './middleware/playground'; import { access as accessLogger, error as errorLogger, } from './middleware/logging'; -import tenantGraphMiddleware from 'talk-server/graph/tenant/middleware'; -import managementGraphMiddleware from 'talk-server/graph/management/middleware'; +import { Redis } from 'ioredis'; -async function createManagementRouter(config: Config, db: Db): Promise { +export interface AppOptions { + config: Config; + mongo: Db; + redis: Redis; +} + +async function createManagementRouter(opts: AppOptions): Promise { const router = express.Router(); - if (config.get('env') === 'development') { + if (opts.config.get('env') === 'development') { // GraphiQL router.get( '/graphiql', - playground(() => ({ - endpoint: `/api/management/graphql`, - })) + playground({ + endpoint: '/api/management/graphql', + }) ); } - // Tenant API - router.use('/graphql', express.json(), managementGraphMiddleware(db)); + // Management API + router.use( + '/graphql', + express.json(), + managementGraphMiddleware(opts.mongo) + ); return router; } -async function createTenantRouter(config: Config, db: Db): Promise { - const router = express.Router({ mergeParams: true }); +async function createTenantRouter(opts: AppOptions): Promise { + const router = express.Router(); - if (config.get('env') === 'development') { + if (opts.config.get('env') === 'development') { // GraphiQL router.get( '/graphiql', - playground(req => ({ - endpoint: `/api/tenant/${req.params.tenantID}/graphql`, - })) + playground({ + endpoint: '/api/tenant/graphql', + }) ); } // Tenant API - router.use('/graphql', express.json(), tenantGraphMiddleware(db)); + router.use('/graphql', express.json(), tenantGraphMiddleware(opts.mongo)); return router; } -async function createAPIRouter(config: Config, db: Db): Promise { +async function createAPIRouter(opts: AppOptions): Promise { // Create a router. - const router = express.Router({ mergeParams: true }); + const router = express.Router(); // Configure the tenant routes. - router.use('/tenant/:tenantID', await createTenantRouter(config, db)); + router.use('/tenant', await createTenantRouter(opts)); // Configure the management routes. - router.use('/management', await createManagementRouter(config, db)); + router.use('/management', await createManagementRouter(opts)); return router; } -async function createRouter(config: Config): Promise { - // Setup MongoDB. - const db = await create(config); - +async function createRouter(opts: AppOptions): Promise { // Create a router. - const router = express.Router({ mergeParams: true }); + const router = express.Router(); - router.use('/api', await createAPIRouter(config, db)); + router.use('/api', await createAPIRouter(opts)); return router; } @@ -83,20 +91,32 @@ async function createRouter(config: Config): Promise { * @param parent the root application to attach the Talk routes/middleware to. */ export async function createApp( - app: Express, - config: Config + parent: Express, + options: AppOptions ): Promise { // Logging - app.use(accessLogger); + parent.use(accessLogger); // Static Files - app.use(serveStatic); + parent.use(serveStatic); // Mount the router. - app.use(await createRouter(config)); + parent.use(await createRouter(options)); // Error Handling - app.use(errorLogger); + parent.use(errorLogger); - return app; + return parent; } + +/** + * startApp will start the given express application. + * + * @param port the port to listen on + * @param app the express application to start + */ +export const startApp = (port: number, app: Express): Promise => + new Promise(async resolve => { + // Listen on the designated port. + const httpServer = app.listen(port, () => resolve(httpServer)); + }); diff --git a/src/core/server/app/middleware/playground.ts b/src/core/server/app/middleware/playground.ts index 5b3bdfa8b..a504b23e2 100644 --- a/src/core/server/app/middleware/playground.ts +++ b/src/core/server/app/middleware/playground.ts @@ -1,16 +1,4 @@ -import { Request, RequestHandler } from 'express'; import { MiddlewareOptions } from 'graphql-playground-html'; import playground from 'graphql-playground-middleware-express'; -export type PlaygroundFn = (req: Request) => MiddlewareOptions; - -export default (fn: PlaygroundFn): RequestHandler => (req, res, next) => { - // Generate the options. - const options: MiddlewareOptions = fn(req); - - // Create the playground handler. - const handler = playground(options); - - // Execute it. - handler(req, res, next); -}; +export default (options: MiddlewareOptions) => playground(options); diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index 60f113f37..061626ae8 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -1,13 +1,13 @@ import { Db } from 'mongodb'; -export interface ContextOptions { +export interface ManagementContextOptions { db: Db; } -export default class TenantContext { +export default class ManagementContext { public db: Db; - constructor({ db }: ContextOptions) { + constructor({ db }: ManagementContextOptions) { this.db = db; } } diff --git a/src/core/server/graph/management/resolvers/index.ts b/src/core/server/graph/management/resolvers/index.ts new file mode 100644 index 000000000..b95bd7347 --- /dev/null +++ b/src/core/server/graph/management/resolvers/index.ts @@ -0,0 +1,5 @@ +import Cursor from '../../common/scalars/cursor'; + +export default { + Cursor, +}; diff --git a/src/core/server/graph/management/schema/index.ts b/src/core/server/graph/management/schema/index.ts index 64bc74fc4..5db233b2d 100644 --- a/src/core/server/graph/management/schema/index.ts +++ b/src/core/server/graph/management/schema/index.ts @@ -1,18 +1,15 @@ -import { addMockFunctionsToSchema } from 'graphql-tools'; +import { addResolveFunctionsToSchema } from 'graphql-tools'; import { getGraphQLProjectConfig } from 'graphql-config'; +import resolvers from '../resolvers'; + // Load the configuration from the provided `.graphqlconfig` file. const config = getGraphQLProjectConfig(__dirname, 'management'); // Get the GraphQLSchema from the configuration. const schema = config.getSchema(); -// Attach resolvers to the schema. -addMockFunctionsToSchema({ - schema, - mocks: { - Cursor: () => new Date().toISOString(), - }, -}); // FIXME: remove mocks +// Attach the resolvers to the schema. +addResolveFunctionsToSchema({ schema, resolvers }); export default schema; diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 8ade5435b..9b16d8189 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -2,7 +2,7 @@ import loaders from './loaders'; import { Db } from 'mongodb'; import { Tenant } from 'talk-server/models/tenant'; -export interface ContextOptions { +export interface TenantContextOptions { tenant?: Tenant; db: Db; } @@ -12,7 +12,7 @@ export default class TenantContext { public db: Db; public tenant?: Tenant; - constructor({ tenant, db }: ContextOptions) { + constructor({ tenant, db }: TenantContextOptions) { this.tenant = tenant; this.loaders = loaders(this); this.db = db; diff --git a/src/core/server/graph/tenant/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts index 53b72d385..73a4d7019 100644 --- a/src/core/server/graph/tenant/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -1,10 +1,8 @@ -import { - addMockFunctionsToSchema, - addResolveFunctionsToSchema, -} from 'graphql-tools'; -import resolvers from '../resolvers'; +import { addResolveFunctionsToSchema } from 'graphql-tools'; import { getGraphQLProjectConfig } from 'graphql-config'; +import resolvers from '../resolvers'; + // Load the configuration from the provided `.graphqlconfig` file. const config = getGraphQLProjectConfig(__dirname, 'tenant'); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 88a6ffa7d..2ea16a848 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -1,28 +1,34 @@ import config, { Config } from './config'; import express, { Express } from 'express'; import http from 'http'; -import { createApp } from './app'; +import { createApp, startApp } from './app'; import logger from './logger'; +import { create as createMongoDB } from './services/mongodb'; +import { create as createRedis } from 'talk-server/services/redis'; -export interface ServerOptions {} +export interface ServerOptions { + config?: Config; +} /** * Server provides an interface to create, start, and manage a Talk Server. */ class Server { - // app is the root application that the server will bind to. - private app: Express; + // parentApp is the root application that the server will bind to. + private parentApp: Express; // config exposes application specific configuration. public config: Config; - // httpServer is the running instance of the HTTP server that will bind to the - // requested port. + // httpServer is the running instance of the HTTP server that will bind to + // the requested port. public httpServer: http.Server; constructor(options: ServerOptions) { - this.app = express(); - this.config = config.validate({ allowed: 'strict' }); + this.parentApp = express(); + this.config = config + .load(options.config || {}) + .validate({ allowed: 'strict' }); } /** @@ -31,21 +37,30 @@ class Server { * * @param parent the optional express application to bind the server to. */ - start = (parent?: Express): Promise => - new Promise(async resolve => { - const port = this.config.get('port'); + public async start(parent?: Express) { + const port = this.config.get('port'); - // Ensure we have an app to bind to. - parent = parent ? parent : this.app; + // Ensure we have an app to bind to. + parent = parent ? parent : this.parentApp; - // Create the Talk App, branching off from the parent app. - const app: Express = await createApp(parent, this.config); + // Setup MongoDB. + const mongo = await createMongoDB(config); - logger.info({ port }, 'now listening'); + // Setup Redis. + const redis = await createRedis(config); - // Listen on the designated port. - this.httpServer = app.listen(port, () => resolve(this)); + // Create the Talk App, branching off from the parent app. + const app = await createApp(parent, { + mongo, + redis, + config: this.config, }); + + // Start the application. + this.httpServer = await startApp(port, app); + + logger.info({ port }, 'now listening'); + } } export default Server; diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 077b272b3..0b97ceb22 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -1,8 +1,8 @@ import { Db, Collection } from 'mongodb'; -import { defaultsDeep } from 'lodash'; +import { merge } from 'lodash'; import dotize from 'dotize'; import uuid from 'uuid'; -import { Omit } from 'talk-common/types'; +import { Omit, Sub } from 'talk-common/types'; function collection(db: Db): Collection { return db.collection('tenants'); @@ -25,6 +25,10 @@ export enum Moderation { export interface Tenant { readonly id: string; + // Domain is set when the tenant is created, and is used to retrieve the + // specific tenant that the API request pertains to. + domain: string; + moderation: Moderation; requireEmailConfirmation: boolean; infoBoxEnable: boolean; @@ -45,8 +49,8 @@ export interface Tenant { editCommentWindowLength: number; charCountEnable: boolean; charCount?: number; - organizationName?: string; - organizationContactEmail?: string; + organizationName: string; + organizationContactEmail: string; // wordlist stores all the banned/suspect words. wordlist: Wordlist; @@ -55,41 +59,67 @@ export interface Tenant { domains: string[]; } -export type CreateTenantInput = Omit; - -const defaults: CreateTenantInput = { - // Default to post moderation. - moderation: Moderation.POST, - - // Email confirmation is default off. - requireEmailConfirmation: false, - infoBoxEnable: false, - questionBoxEnable: false, - premodLinksEnable: false, - autoCloseStream: false, - // Two weeks timeout. - closedTimeout: 60 * 60 * 24 * 7 * 2, - disableCommenting: false, - editCommentWindowLength: 30 * 1000, - charCountEnable: false, - wordlist: { - suspect: [], - banned: [], - }, - domains: [], -}; +/** + * CreateTenantInput is the set of properties that can be set when a given + * Tenant is created. The remainder of the properties are set from defaults and + * are modifiable via the update method. + */ +export type CreateTenantInput = Pick< + Tenant, + 'domain' | 'organizationName' | 'organizationContactEmail' | 'domains' +>; +/** + * create will create a new Tenant. + * + * @param db the MongoDB connection used to create the tenant. + * @param input the customizable parts of the Tenant available during creation + */ export async function create( db: Db, - input: Partial + input: CreateTenantInput ): Promise> { - const tenant = defaultsDeep({ id: uuid.v4() }, input, defaults); + const defaults: Sub = { + // Create a new ID. + id: uuid.v4(), + // Default to post moderation. + moderation: Moderation.POST, + + // Email confirmation is default off. + requireEmailConfirmation: false, + infoBoxEnable: false, + questionBoxEnable: false, + premodLinksEnable: false, + autoCloseStream: false, + + // Two weeks timeout. + closedTimeout: 60 * 60 * 24 * 7 * 2, + disableCommenting: false, + editCommentWindowLength: 30 * 1000, + charCountEnable: false, + wordlist: { + suspect: [], + banned: [], + }, + }; + + // Create the new Tenant by merging it together with the defaults. + const tenant = merge({}, input, defaults); + + // Insert the Tenant into the database. await collection(db).insert(tenant); return tenant; } +export async function retrieveByDomain( + db: Db, + domain: string +): Promise> { + return collection(db).findOne({ domain }); +} + export async function retrieve(db: Db, id: string): Promise> { return collection(db).findOne({ id }); } @@ -109,6 +139,23 @@ export async function retrieveMany( return ids.map(id => tenants.find(tenant => tenant.id === id)); } +export async function retrieveManyByDomain( + db: Db, + domains: string[] +): Promise[]> { + const cursor = await collection(db).find({ + domain: { + $in: domains, + }, + }); + + const tenants = await cursor.toArray(); + + return domains.map(domain => + tenants.find(tenant => tenant.domain === domain) + ); +} + export async function retrieveAll(db: Db): Promise[]> { return collection(db) .find({})