feat: transitioned to vanilla apollo-server-express

This commit is contained in:
Wyatt Johnson
2020-08-14 17:59:13 -06:00
parent 4a94b81230
commit 22fc1438f2
26 changed files with 530 additions and 537 deletions
@@ -1,80 +0,0 @@
import { generateSchemaHash } from "apollo-server-core/dist/utils/schemaHash";
import { CLIENT_ID_HEADER } from "coral-common/constants";
import { AppOptions } from "coral-server/app";
import { graphqlMiddleware } from "coral-server/app/middleware/graphql";
import GraphContext, { GraphContextOptions } from "coral-server/graph/context";
import {
Request,
RequestHandler,
TenantCoralRequest,
} from "coral-server/types/express";
export type GraphMiddlewareOptions = Pick<
AppOptions,
| "config"
| "i18n"
| "mailerQueue"
| "scraperQueue"
| "rejectorQueue"
| "notifierQueue"
| "webhookQueue"
| "mongo"
| "redis"
| "schema"
| "signingConfig"
| "pubsub"
| "tenantCache"
| "metrics"
| "broker"
| "reporter"
>;
export const graphQLHandler = ({
schema,
config,
metrics,
...options
}: GraphMiddlewareOptions): RequestHandler<TenantCoralRequest> => {
// Generate the schema hash.
const schemaHash = generateSchemaHash(schema);
return graphqlMiddleware(
config,
async (req: Request<TenantCoralRequest>) => {
// Pull out some useful properties from Coral.
const { id, now, tenant, logger, persisted } = req.coral;
// Create some new options to store the tenant context details inside.
const opts: GraphContextOptions = {
...options,
id,
now,
req,
persisted,
config,
tenant,
logger,
};
// Add the user if there is one.
if (req.user) {
opts.user = req.user;
}
// Add the clientID if there is one on the request.
const clientID = req.get(CLIENT_ID_HEADER);
if (clientID) {
// TODO: (wyattjoh) validate length
opts.clientID = clientID;
}
return {
schema,
schemaHash,
context: new GraphContext(opts),
};
},
metrics
);
};
@@ -1,6 +1,5 @@
export * from "./account";
export * from "./auth";
export * from "./graphql";
export * from "./health";
export * from "./install";
export * from "./version";
@@ -0,0 +1 @@
export * from "./tenant";
@@ -0,0 +1,110 @@
import { ApolloServer } from "apollo-server-express";
import { CLIENT_ID_HEADER } from "coral-common/constants";
import { AppOptions } from "coral-server/app";
import GraphContext, { GraphContextOptions } from "coral-server/graph/context";
import {
ErrorApolloServerPlugin,
LoggerApolloServerPlugin,
MetricsApolloServerPlugin,
} from "coral-server/graph/plugins";
import { Request, TenantCoralRequest } from "coral-server/types/express";
type ContextProviderOptions = Omit<AppOptions, "schema" | "metrics">;
function contextProvider(options: ContextProviderOptions) {
return ({ req }: { req: Request<TenantCoralRequest> }) => {
// Grab the details from the Coral request.
const { id, now, tenant, logger, persisted } = req.coral;
// Create some new options to store the tenant context details inside.
const opts: GraphContextOptions = {
...options,
id,
now,
req,
persisted,
tenant,
logger,
};
// Add the user if there is one.
if (req.user) {
opts.user = req.user;
}
// Add the clientID if there is one on the request.
const clientID = req.get(CLIENT_ID_HEADER);
if (clientID) {
// TODO: (wyattjoh) validate length
opts.clientID = clientID;
}
// Return the compiled context.
return new GraphContext(opts);
};
}
export const apolloGraphQLMiddleware = ({
schema,
metrics,
...options
}: AppOptions) => {
// Create the ApolloServer that we'll use to get the middleware from.
const server = new ApolloServer({
// Provide the executable schema that we assembled earlier.
schema,
// Create the context provider that'll create a new context for each
// request.
context: contextProvider(options),
// Introspection is enabled when we aren't in a production environment or if
// the GraphiQL is enabled.
introspection:
options.config.get("env") !== "production" ||
options.config.get("enable_graphiql"),
// Disable uploads, Coral doesn't handle any file uploads.
uploads: false,
// Disable the embedded playground, Coral provides it's own GraphiQL
// interface.
playground: false,
// Disable engine, Coral doesn't use it.
engine: false,
// Disable cache control, Coral doesn't use it yet.
cacheControl: false,
// Disable subscriptions as we'll be providing it seperatly.
subscriptions: false,
// Configure plugins to be ran on requests.
plugins: [
ErrorApolloServerPlugin,
LoggerApolloServerPlugin,
MetricsApolloServerPlugin(metrics),
],
// Disable the debug mode, as we already add in our logging function.
debug: false,
});
// Get the GraphQL middleware.
return server.getMiddleware({
// Disable the health check endpoint, Coral does not use this endpoint and
// instead uses the /api/health endpoint.
disableHealthCheck: true,
// Disable CORS, Coral does not allow cross origin requests.
cors: false,
// Disable the body parser, we will add our own.
bodyParserConfig: false,
// Configure the path.
path: "/graphql",
});
};
@@ -1,76 +0,0 @@
import { GraphQLExtension, GraphQLOptions } from "apollo-server-express";
// TODO: when https://github.com/apollographql/apollo-server/pull/1907 is merged, update this import path
import {
ExpressGraphQLOptionsFunction,
graphqlExpress,
} from "apollo-server-express/dist/expressApollo";
import { Handler } from "express";
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
import { Config } from "coral-server/config";
import {
ErrorWrappingExtension,
LoggerExtension,
MetricsExtension,
} from "coral-server/graph/extensions";
import { Metrics } from "coral-server/services/metrics";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
const NoIntrospection = (context: ValidationContext) => ({
Field(node: FieldDefinitionNode) {
if (node.name.value === "__schema" || node.name.value === "__type") {
context.reportError(
new GraphQLError(
"GraphQL introspection is not allowed in production, but the query contained __schema or __type.",
[node]
)
);
}
},
});
/**
* graphqlMiddleware wraps the GraphQL middleware server with some custom
* extension management.
*
* @param config application configuration
* @param requestOptions options to pass to the graphql server
*/
const graphqlMiddleware = (
config: Config,
requestOptions: ExpressGraphQLOptionsFunction,
metrics: Metrics
): Handler => {
const extensions: Array<() => GraphQLExtension> = [
() => new ErrorWrappingExtension(),
() => new LoggerExtension(),
// Pass the metrics to the extension so it can increment.
() => new MetricsExtension(metrics),
];
// Create a new baseOptions that will be merged into the new options.
const baseOptions: Omit<GraphQLOptions, "schema" | "schemaHash"> = {
// Disable the debug mode, as we already add in our logging function.
debug: false,
extensions,
};
if (config.get("env") === "production" && !config.get("enable_graphiql")) {
// Disable introspection in production.
baseOptions.validationRules = [NoIntrospection];
}
// Generate the actual middleware.
return graphqlExpress(async (req, res) => {
// Resolve the options for the GraphQL middleware.
const options = await requestOptions(req, res);
// Provide the options.
return {
...options,
...baseOptions,
};
});
};
export default graphqlMiddleware;
@@ -1,2 +1,2 @@
export { default as graphqlMiddleware } from "./graphqlMiddleware";
export { default as persistedQueryMiddleware } from "./persistedQueryMiddleware";
export * from "./apolloServer";
export * from "./persistedQuery";
@@ -14,7 +14,7 @@ type PersistedQueryMiddlewareOptions = Pick<
"persistedQueryCache" | "persistedQueriesRequired"
>;
const persistedQueryMiddleware = ({
export const persistedQueryMiddleware = ({
persistedQueriesRequired,
persistedQueryCache,
}: PersistedQueryMiddlewareOptions): RequestHandler<
@@ -64,5 +64,3 @@ const persistedQueryMiddleware = ({
return next(err);
}
};
export default persistedQueryMiddleware;
+17
View File
@@ -0,0 +1,17 @@
export * from "./csp";
export * from "./graphql";
export * from "./passport";
export * from "./basicAuth";
export * from "./cacheHeaders";
export * from "./error";
export * from "./installed";
export * from "./json";
export * from "./loggedIn";
export * from "./logging";
export * from "./metrics";
export * from "./notFound";
export * from "./playground";
export * from "./role";
export * from "./serveStatic";
export * from "./tenant";
export * from "./userLimiter";
+5 -9
View File
@@ -1,24 +1,20 @@
import { isInstalled } from "coral-server/services/tenant";
import { RequestHandler } from "coral-server/types/express";
export interface InstalledMiddlewareOptions {
interface Options {
redirectURL?: string;
redirectIfInstalled?: boolean;
}
const DefaultInstalledMiddlewareOptions: Required<InstalledMiddlewareOptions> = {
const defaultOptions: Required<Options> = {
redirectIfInstalled: false,
redirectURL: "/install",
};
export const installedMiddleware = ({
redirectIfInstalled = DefaultInstalledMiddlewareOptions.redirectIfInstalled,
redirectURL = DefaultInstalledMiddlewareOptions.redirectURL,
}: InstalledMiddlewareOptions = DefaultInstalledMiddlewareOptions): RequestHandler => async (
req,
res,
next
) => {
redirectIfInstalled = defaultOptions.redirectIfInstalled,
redirectURL = defaultOptions.redirectURL,
}: Options = defaultOptions): RequestHandler => async (req, res, next) => {
const installed = await isInstalled(req.coral.cache.tenant, req.hostname);
// If Coral is installed, and redirectIfInstall is true, then it will
@@ -4,8 +4,6 @@ import { createTimer } from "coral-server/helpers";
import { Metrics } from "coral-server/services/metrics";
import { RequestHandler } from "coral-server/types/express";
export type MetricsRecorderOptions = Metrics;
export const metricsRecorder = ({
httpRequestsTotal,
httpRequestDurationMilliseconds,
@@ -32,14 +32,12 @@ export type VerifyCallback = (
info?: { message: string }
) => void;
export type PassportOptions = Pick<
type Options = Pick<
AppOptions,
"mongo" | "redis" | "config" | "tenantCache" | "signingConfig"
>;
export function createPassport(
options: PassportOptions
): passport.Authenticator {
export function createPassport(options: Options): passport.Authenticator {
// Create the authenticator.
const auth = new Authenticator();
+2 -2
View File
@@ -5,7 +5,7 @@ import logger from "coral-server/logger";
import { TenantCache } from "coral-server/services/tenant/cache";
import { RequestHandler } from "coral-server/types/express";
export interface MiddlewareOptions {
interface Options {
cache: TenantCache;
passNoTenant?: boolean;
}
@@ -13,7 +13,7 @@ export interface MiddlewareOptions {
export const tenantMiddleware = ({
cache,
passNoTenant = false,
}: MiddlewareOptions): RequestHandler => async (req, res, next) => {
}: Options): RequestHandler => async (req, res, next) => {
try {
if (!req.coral) {
const id = uuid();
@@ -4,7 +4,7 @@ import { RequestLimiter } from "coral-server/app/request/limiter";
import { Config } from "coral-server/config";
import { RequestHandler } from "coral-server/types/express";
export interface MiddlewareOptions {
interface Options {
redis: Redis;
config: Config;
}
@@ -12,7 +12,7 @@ export interface MiddlewareOptions {
export const userLimiterMiddleware = ({
redis,
config,
}: MiddlewareOptions): RequestHandler => {
}: Options): RequestHandler => {
const limiter = new RequestLimiter({
redis,
ttl: "1m",
+17 -13
View File
@@ -2,17 +2,19 @@ import express from "express";
import passport from "passport";
import { AppOptions } from "coral-server/app";
import { graphQLHandler } from "coral-server/app/handlers";
import { oembedHandler } from "coral-server/app/handlers/api/oembed/oembed";
import { cspSiteMiddleware } from "coral-server/app/middleware/csp/tenant";
import { JSONErrorHandler } from "coral-server/app/middleware/error";
import { persistedQueryMiddleware } from "coral-server/app/middleware/graphql";
import { jsonMiddleware } from "coral-server/app/middleware/json";
import { loggedInMiddleware } from "coral-server/app/middleware/loggedIn";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import { authenticate } from "coral-server/app/middleware/passport";
import { roleMiddleware } from "coral-server/app/middleware/role";
import { tenantMiddleware } from "coral-server/app/middleware/tenant";
import {
apolloGraphQLMiddleware,
authenticate,
cspSiteMiddleware,
JSONErrorHandler,
jsonMiddleware,
loggedInMiddleware,
notFoundMiddleware,
persistedQueryMiddleware,
roleMiddleware,
tenantMiddleware,
} from "coral-server/app/middleware";
import { STAFF_ROLES } from "coral-server/models/user/constants";
import { createNewAccountRouter } from "./account";
@@ -55,15 +57,17 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
router.get("/oembed", cspSiteMiddleware(app), oembedHandler(app));
// Configure the GraphQL route.
// Configure the GraphQL route middleware.
router.use(
"/graphql",
authenticate(options.passport),
jsonMiddleware(app.config.get("max_request_size")),
persistedQueryMiddleware(app),
graphQLHandler(app)
persistedQueryMiddleware(app)
);
// Attach the GraphQL router (which will be mounted on the same path).
router.use(apolloGraphQLMiddleware(app));
router.use(
"/dashboard",
authenticate(options.passport),
@@ -1,43 +0,0 @@
import { ExecutionArgs } from "graphql";
import { EndHandler, GraphQLExtension } from "graphql-extensions";
import GraphContext from "coral-server/graph/context";
import { createTimer } from "coral-server/helpers";
import { Metrics } from "coral-server/services/metrics";
import { getOperationMetadata } from "./helpers";
export class MetricsExtension implements GraphQLExtension<GraphContext> {
constructor(private metrics: Metrics) {}
public executionDidStart(o: {
executionArgs: ExecutionArgs;
}): EndHandler | void {
// Only try to log things if the context is provided.
if (o.executionArgs.contextValue) {
// Grab the start time so we can calculate the time it takes to execute
// the graph query.
const timer = createTimer();
return () => {
// Compute the end time.
const responseTime = timer();
// Get the request metadata.
const { operation, operationName } = getOperationMetadata(
o.executionArgs.document
);
if (operation && operationName) {
// Increment the graph query value, tagging with the name of the query.
this.metrics.executedGraphQueriesTotalCounter
.labels(operation, operationName)
.inc();
this.metrics.graphQLExecutionTimingsHistogram
.labels(operation, operationName)
.observe(responseTime);
}
};
}
}
}
@@ -1,82 +0,0 @@
import {
DocumentNode,
GraphQLFormattedError,
OperationDefinitionNode,
OperationTypeNode,
} from "graphql";
import { PersistedQuery } from "coral-server/models/queries";
export interface OperationMetadata {
operationName: string;
operation: OperationTypeNode;
}
/**
* getOperationMetadata will extract the operation metadata from the document
* node.
*
* @param doc the document node that can be used to extract operation metadata
* from
*/
export const getOperationMetadata = (
doc: DocumentNode
): Partial<OperationMetadata> => {
if (doc.kind === "Document") {
const operationDefinition = doc.definitions.find(
({ kind }) => kind === "OperationDefinition"
) as OperationDefinitionNode | undefined;
if (operationDefinition) {
let operationName: string | undefined;
if (operationDefinition.name) {
operationName = operationDefinition.name.value;
}
return {
operationName,
operation: operationDefinition.operation,
};
}
}
return {};
};
interface PersistedQueryOperationMetadata extends OperationMetadata {
persistedQueryID: string;
persistedQueryBundle: string;
persistedQueryVersion: string;
}
/**
* getPersistedQueryMetadata will remap the persisted query to the operation
* metadata.
*
* @param persisted persisted query to remap to operation metadata
*/
export const getPersistedQueryMetadata = ({
id: persistedQueryID,
operation,
operationName,
bundle: persistedQueryBundle,
version: persistedQueryVersion,
}: PersistedQuery): PersistedQueryOperationMetadata => ({
persistedQueryID,
persistedQueryBundle,
persistedQueryVersion,
operation,
operationName,
});
/**
* getOriginalError tries to return the original error from a
* formatted GraphQL error.
*
* @param err A GraphQL Formatted Error
*/
export const getOriginalError = (err: GraphQLFormattedError) => {
if ((err as any).originalError) {
return (err as any).originalError as Error;
}
return null;
};
@@ -1,3 +0,0 @@
export * from "./ErrorWrappingExtension";
export * from "./LoggerExtension";
export * from "./MetricsExtension";
+1 -1
View File
@@ -3,7 +3,7 @@ import { parse } from "graphql";
import path from "path";
import { version } from "coral-common/version";
import { getOperationMetadata } from "coral-server/graph/extensions/helpers";
import { getOperationMetadata } from "coral-server/graph/plugins";
import logger from "coral-server/logger";
import { PersistedQuery } from "coral-server/models/queries";
+22
View File
@@ -0,0 +1,22 @@
import { ApolloServerPlugin } from "apollo-server-plugin-base";
import GraphContext from "../context";
import { enrichError } from "./helpers";
export const ErrorApolloServerPlugin: ApolloServerPlugin<GraphContext> = {
requestDidStart() {
return {
willSendResponse({ response, context }) {
// If there's any errors on the response, we need to enrich their
// extensions with translated messages.
if (response.errors) {
response.errors = response.errors.map((err) =>
enrichError(context, err)
);
}
},
};
},
};
export default ErrorApolloServerPlugin;
@@ -1,6 +1,10 @@
import { ApolloError } from "apollo-server-core";
import { GraphQLFormattedError } from "graphql";
import { GraphQLExtension, GraphQLResponse } from "graphql-extensions";
import { ApolloError } from "apollo-server-express";
import {
DocumentNode,
GraphQLFormattedError,
OperationDefinitionNode,
OperationTypeNode,
} from "graphql";
import { merge } from "lodash";
import {
@@ -8,9 +12,83 @@ import {
InternalDevelopmentError,
WrappedInternalError,
} from "coral-server/errors";
import GraphContext from "coral-server/graph/context";
import { PersistedQuery } from "coral-server/models/queries";
import { getOriginalError } from "./helpers";
import GraphContext from "../context";
export interface OperationMetadata {
operationName: string;
operation: OperationTypeNode;
}
/**
* getOperationMetadata will extract the operation metadata from the document
* node.
*
* @param doc the document node that can be used to extract operation metadata
* from
*/
export const getOperationMetadata = (
doc: DocumentNode
): Partial<OperationMetadata> => {
if (doc.kind === "Document") {
const operationDefinition = doc.definitions.find(
({ kind }) => kind === "OperationDefinition"
) as OperationDefinitionNode | undefined;
if (operationDefinition) {
let operationName: string | undefined;
if (operationDefinition.name) {
operationName = operationDefinition.name.value;
}
return {
operationName,
operation: operationDefinition.operation,
};
}
}
return {};
};
interface PersistedQueryOperationMetadata extends OperationMetadata {
persistedQueryID: string;
persistedQueryBundle: string;
persistedQueryVersion: string;
}
/**
* getPersistedQueryMetadata will remap the persisted query to the operation
* metadata.
*
* @param persisted persisted query to remap to operation metadata
*/
export const getPersistedQueryMetadata = ({
id: persistedQueryID,
operation,
operationName,
bundle: persistedQueryBundle,
version: persistedQueryVersion,
}: PersistedQuery): PersistedQueryOperationMetadata => ({
persistedQueryID,
persistedQueryBundle,
persistedQueryVersion,
operation,
operationName,
});
/**
* getOriginalError tries to return the original error from a
* formatted GraphQL error.
*
* @param err A GraphQL Formatted Error
*/
export const getOriginalError = (err: GraphQLFormattedError) => {
if ((err as any).originalError) {
return (err as any).originalError as Error;
}
return null;
};
function hoistCoralErrorExtensions(
ctx: GraphContext,
@@ -100,22 +178,3 @@ export function enrichError(
return err;
}
export class ErrorWrappingExtension implements GraphQLExtension<GraphContext> {
public willSendResponse(o: {
graphqlResponse: GraphQLResponse;
context: GraphContext;
}): void | { graphqlResponse: GraphQLResponse; context: GraphContext } {
if (o.graphqlResponse.errors) {
return {
...o,
graphqlResponse: {
...o.graphqlResponse,
errors: o.graphqlResponse.errors.map((err) =>
enrichError(o.context, err)
),
},
};
}
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./error";
export * from "./logger";
export * from "./metrics";
export * from "./helpers";
@@ -1,9 +1,5 @@
import { DocumentNode, ExecutionArgs, GraphQLFormattedError } from "graphql";
import {
EndHandler,
GraphQLExtension,
GraphQLResponse,
} from "graphql-extensions";
import { ApolloServerPlugin } from "apollo-server-plugin-base";
import { DocumentNode, GraphQLFormattedError } from "graphql";
import GraphContext from "coral-server/graph/context";
import { createTimer } from "coral-server/helpers";
@@ -16,10 +12,9 @@ export function logAndReportError(
ctx: GraphContext,
err: GraphQLFormattedError
) {
ctx.logger.error({ err }, "graphql query error");
// If there's no reporter active, then return now.
// If there's no reporter active, then just log what we got and return now.
if (!ctx.reporter) {
ctx.logger.error({ err }, "graphql query error");
return;
}
@@ -106,35 +101,25 @@ export function logQuery(
}
}
export class LoggerExtension implements GraphQLExtension<GraphContext> {
public executionDidStart(o: {
executionArgs: ExecutionArgs;
}): EndHandler | void {
// Only try to log things if the context is provided.
if (o.executionArgs.contextValue) {
// Grab the start time so we can calculate the time it takes to execute
// the graph query.
const timer = createTimer();
return () => {
// Log out the details of the request.
logQuery(
o.executionArgs.contextValue,
o.executionArgs.document,
undefined,
timer()
);
};
}
}
export const LoggerApolloServerPlugin: ApolloServerPlugin<GraphContext> = {
requestDidStart() {
return {
willSendResponse({ response, context }) {
if (response.errors) {
// Log out the errors on this request.
response.errors.forEach((err) => logAndReportError(context, err));
}
},
executionDidStart({ document, context }) {
// Grab the start time so we can calculate the time it takes to execute
// the graph query.
const timer = createTimer();
public willSendResponse(response: {
graphqlResponse: GraphQLResponse;
context: GraphContext;
}): void {
if (response.graphqlResponse.errors) {
response.graphqlResponse.errors.forEach((err) =>
logAndReportError(response.context, err)
);
}
}
}
return function executionDidEnd() {
// Log out the details of this request.
logQuery(context, document, context.persisted, timer());
};
},
};
},
};
+40
View File
@@ -0,0 +1,40 @@
import { ApolloServerPlugin } from "apollo-server-plugin-base";
import { createTimer } from "coral-server/helpers";
import { Metrics } from "coral-server/services/metrics";
import { getOperationMetadata } from "./helpers";
export const MetricsApolloServerPlugin = (
metrics: Metrics
): ApolloServerPlugin => ({
requestDidStart() {
return {
executionDidStart({ document }) {
// Grab the start time so we can calculate the time it takes to execute
// the graph query.
const timer = createTimer();
return function executionDidEnd() {
// Compute the end time.
const responseTime = timer();
// Get the request metadata.
const { operation, operationName } = getOperationMetadata(document);
if (operation && operationName) {
// Increment the graph query value, tagging with the name of the query.
metrics.executedGraphQueriesTotalCounter
.labels(operation, operationName)
.inc();
metrics.graphQLExecutionTimingsHistogram
.labels(operation, operationName)
.observe(responseTime);
}
};
},
};
},
});
export default MetricsApolloServerPlugin;
@@ -29,13 +29,13 @@ import {
TenantNotFoundError,
WrappedInternalError,
} from "coral-server/errors";
import { getPersistedQuery } from "coral-server/graph/persisted";
import {
enrichError,
getOperationMetadata,
logAndReportError,
logQuery,
} from "coral-server/graph/extensions";
import { getOperationMetadata } from "coral-server/graph/extensions/helpers";
import { getPersistedQuery } from "coral-server/graph/persisted";
} from "coral-server/graph/plugins";
import logger from "coral-server/logger";
import { PersistedQuery } from "coral-server/models/queries";
import { hasStaffRole } from "coral-server/models/user/helpers";