[CORL-149] Persisted Queries (#2445)

* feat: enable persisted queries on the client

* fix: use `id` inside websocket message

* feat: initial server support for PQ

* feat: deeper server support

* feat: abstracted persisted query replacing logic
This commit is contained in:
Vinh
2019-08-16 04:03:32 +07:00
committed by Wyatt Johnson
parent 635e740fc0
commit 43b6a2cdcd
30 changed files with 1268 additions and 465 deletions
+1
View File
@@ -21,3 +21,4 @@ dist
*.css.d.ts
__generated__
README.md.orig
persisted-queries.json
+5 -5
View File
@@ -27,7 +27,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run --silent generate:relay-stream", {
executor: new CommandExecutor("npm run --silent generate:relay:stream", {
runOnInit: true,
}),
},
@@ -44,7 +44,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run generate:relay-account", {
executor: new CommandExecutor("npm run generate:relay:account", {
runOnInit: true,
}),
},
@@ -61,7 +61,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run --silent generate:relay-admin", {
executor: new CommandExecutor("npm run --silent generate:relay:admin", {
runOnInit: true,
}),
},
@@ -78,7 +78,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run --silent generate:relay-auth", {
executor: new CommandExecutor("npm run --silent generate:relay:auth", {
runOnInit: true,
}),
},
@@ -95,7 +95,7 @@ const config: Config = {
"**/test/**/*",
"core/**/*.spec.*",
],
executor: new CommandExecutor("npm run --silent generate:relay-install", {
executor: new CommandExecutor("npm run --silent generate:relay:install", {
runOnInit: true,
}),
},
+593 -190
View File
File diff suppressed because it is too large Load Diff
+14 -9
View File
@@ -20,18 +20,21 @@
],
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
"scripts": {
"build": "NODE_ENV=production npm-run-all generate --parallel lint:client build:client build:server",
"build": "NODE_ENV=production npm-run-all generate-persist --parallel lint:client build:client build:server",
"build:development": "NODE_ENV=development npm-run-all generate --parallel lint:client build:client build:server",
"build:client": "ts-node --transpile-only ./scripts/build.ts",
"build:server": "gulp server",
"doctoc": "doctoc --title='## Table of Contents' --github README.md",
"generate": "npm-run-all --parallel generate:*",
"generate": "npm-run-all generate:css-types generate:schema generate:relay",
"generate-persist": "npm-run-all generate:css-types generate:schema generate:relay-persist",
"generate:css-types": "tcm src/core/client/",
"generate:relay-stream": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
"generate:relay-account": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/account --schema tenant",
"generate:relay-auth": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
"generate:relay-install": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
"generate:relay-admin": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
"generate:relay": "npm-run-all --parallel generate:relay:*",
"generate:relay-persist": "npm-run-all --parallel 'generate:relay:* -- --persist'",
"generate:relay:stream": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
"generate:relay:account": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/account --schema tenant",
"generate:relay:auth": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
"generate:relay:install": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
"generate:relay:admin": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
"generate:schema": "node ./scripts/generateSchemaTypes.js",
"docz": "docz",
"start": "NODE_ENV=production node dist/index.js",
@@ -55,7 +58,7 @@
"@coralproject/bunyan-prettystream": "^0.1.4",
"@types/archiver": "^3.0.0",
"akismet-api": "^4.2.0",
"apollo-server-express": "^2.1.0",
"apollo-server-express": "^2.8.1",
"archiver": "^3.0.3",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
@@ -102,6 +105,7 @@
"linkify-it": "^2.1.0",
"linkifyjs": "^2.1.8",
"lodash": "^4.17.10",
"lru-cache": "^5.1.1",
"luxon": "^1.12.0",
"metascraper-author": "^3.11.8",
"metascraper-date": "^3.11.4",
@@ -126,7 +130,7 @@
"prom-client": "^11.3.0",
"proxy-agent": "^3.1.0",
"querystringify": "^2.1.0",
"react-relay-network-modern": "^3.0.4",
"react-relay-network-modern": "^4.0.4",
"source-map-support": "^0.5.12",
"stack-utils": "^1.0.2",
"striptags": "^3.1.1",
@@ -184,6 +188,7 @@
"@types/linkify-it": "^2.1.0",
"@types/linkifyjs": "^2.1.1",
"@types/lodash": "^4.14.118",
"@types/lru-cache": "^5.1.0",
"@types/luxon": "^1.12.0",
"@types/marked": "^0.6.0",
"@types/mini-css-extract-plugin": "^0.2.0",
+26 -3
View File
@@ -2,7 +2,7 @@
import program from "commander";
import spawn from "cross-spawn";
import fs from "fs";
import fs from "fs-extra";
import path from "path";
const config = JSON.parse(
@@ -14,6 +14,7 @@ program
.usage("--src ./src/core/client/stream --schema tenant")
.option("--src <folder>", "Find gql recursively in this folder")
.option("--schema <schema>", "Identifier of schema")
.option("--persist", "Use persisted queries")
.description("Compile relay gql data")
.parse(process.argv);
@@ -57,8 +58,30 @@ const args = [
`${program.src}/__generated__`,
"--schema",
config.projects[program.schema].schemaPath,
// "--persist-output",
// `${program.src}/persisted-queries.json`,
];
// Set the persisted query path.
const persist = program.persist
? `${program.src}/persisted-queries.json`
: null;
if (persist) {
args.push("--persist-output", persist);
}
spawn.sync("relay-compiler", args, { stdio: "inherit" });
if (persist) {
if (fs.existsSync(persist)) {
// Create the new filename.
const name = path.basename(program.src);
const generated = "./src/core/server/graph/tenant/persisted/__generated__";
// Create the generated directory if it doesn't exist.
fs.ensureDirSync(generated);
// Copy the file over to the destination directory.
fs.copySync(persist, `${generated}/${name}.json`, {
overwrite: true,
});
}
}
@@ -4,13 +4,16 @@ import {
Disposable,
Variables,
} from "react-relay-network-modern/es";
import { SubscriptionClient } from "subscriptions-transport-ws";
import {
OperationOptions,
SubscriptionClient,
} from "subscriptions-transport-ws";
import { ACCESS_TOKEN_PARAM, CLIENT_ID_PARAM } from "coral-common/constants";
import { ERROR_CODES } from "coral-common/errors";
/**
* SubscriptionRequest containts the subscription
* SubscriptionRequest contains the subscription
* request data that comes from Relay.
*/
export interface SubscriptionRequest {
@@ -109,17 +112,29 @@ export default function createManagedSubscriptionClient(
},
});
}
const subscription = subscriptionClient
.request({
operationName: operation.name,
query: operation.text!,
variables,
})
.subscribe({
next({ data }) {
observer.onNext({ data });
},
});
if (!operation.text && !operation.id) {
throw Error("Neither subscription query nor id was provided.");
}
const opts: OperationOptions = {
operationName: operation.name,
// subscriptions-transport-ws requires `query` to be set to an non-empty string.
// With persisted queries we only have the id, so set this to
// "PERSISTED_QUERY" to get around validation.
query: operation.text || "PERSISTED_QUERY",
variables,
};
// Query is not available which means we can use the id from persisted queries.
if (!operation.text) {
opts.id = operation.id;
}
const subscription = subscriptionClient.request(opts).subscribe({
next({ data }) {
observer.onNext({ data });
},
});
request.unsubscribe = () => {
subscription.unsubscribe();
};
@@ -1,6 +1,5 @@
import {
authMiddleware,
batchMiddleware,
cacheMiddleware,
RelayNetworkLayer,
retryMiddleware,
@@ -11,6 +10,7 @@ import {
import clientIDMiddleware from "./clientIDMiddleware";
import { ManagedSubscriptionClient } from "./createManagedSubscriptionClient";
import customErrorMiddleware from "./customErrorMiddleware";
import persistedQueriesGetMethodMiddleware from "./persistedQueriesGetMethodMiddleware";
export type TokenGetter = () => string;
@@ -51,11 +51,6 @@ export default function createNetwork(
urlMiddleware({
url: () => Promise.resolve(graphqlURL),
}),
batchMiddleware({
batchUrl: (requestMap: any) => Promise.resolve(graphqlURL),
batchTimeout: 0,
allowMutations: true,
}),
retryMiddleware({
fetchTimeout: 15000,
retryDelays: (attempt: number) => Math.pow(2, attempt + 4) * 100,
@@ -66,6 +61,7 @@ export default function createNetwork(
token: tokenGetter,
}),
clientIDMiddleware(clientID),
persistedQueriesGetMethodMiddleware,
],
{ subscribeFn: createSubscriptionFunction(subscriptionClient) }
);
@@ -0,0 +1,46 @@
import { Middleware, RelayRequestAny } from "react-relay-network-modern/es";
import { modifyQuery } from "coral-framework/utils";
function hasMutations(req: RelayRequestAny): boolean {
return req.isMutation();
}
function queriesAreEmpty(req: RelayRequestAny): boolean {
return req.getQueryString() === "";
}
/**
* persistedQueriesGetMethodMiddleware will use the GET method instead of POST for
* all request excluding mutations when persisted queries are used.
* The request data will be encoded in base64url and set in the GET query string under
* the variable "d=".
*/
const persistedQueriesGetMethodMiddleware: Middleware = next => async req => {
if (queriesAreEmpty(req) && !hasMutations(req)) {
// Pull the body out (serializing it) and delete it off of the original
// fetch options.
const body: Record<string, any> = JSON.parse(req.fetchOpts.body as string);
delete req.fetchOpts.body;
// Reconfigure the fetch for GET.
req.fetchOpts.method = "GET";
// Rebuild the query parameters for GET.
const params: Record<string, string> = { query: "" };
for (const key in body) {
if (!body.hasOwnProperty(key)) {
continue;
}
const value = body[key];
params[key] = typeof value === "string" ? value : JSON.stringify(value);
}
// Combine the new parameters onto the URL.
req.fetchOpts.url = modifyQuery(req.fetchOpts.url as string, params);
}
return next(req);
};
export default persistedQueriesGetMethodMiddleware;
+13
View File
@@ -295,4 +295,17 @@ export enum ERROR_CODES {
* someone now allowed when it is disabled on the tenant level.
*/
LIVE_UPDATES_DISABLED = "LIVE_UPDATES_DISABLED",
/**
* PERSISTED_QUERY_NOT_FOUND is returned when a query is executed specifying a
* persisted query that can not be found.
*/
PERSISTED_QUERY_NOT_FOUND = "PERSISTED_QUERY_NOT_FOUND",
/**
* RAW_QUERY_NOT_AUTHORIZED is returned when a query is executed that is not a
* persisted query when the server has configured such queries are required by
* all non-admin users.
*/
RAW_QUERY_NOT_AUTHORIZED = "RAW_QUERY_NOT_AUTHORIZED",
}
+41 -46
View File
@@ -1,9 +1,6 @@
import { CLIENT_ID_HEADER } from "coral-common/constants";
import { AppOptions } from "coral-server/app";
import {
graphqlBatchMiddleware,
graphqlMiddleware,
} from "coral-server/app/middleware/graphql";
import { graphqlMiddleware } from "coral-server/app/middleware/graphql";
import TenantContext, {
TenantContextOptions,
} from "coral-server/graph/tenant/context";
@@ -30,53 +27,51 @@ export const graphQLHandler = ({
metrics,
...options
}: GraphMiddlewareOptions): RequestHandler =>
graphqlBatchMiddleware(
graphqlMiddleware(
config,
async (req: Request) => {
if (!req.coral) {
throw new Error("coral was not set");
}
graphqlMiddleware(
config,
async (req: Request) => {
if (!req.coral) {
throw new Error("coral was not set");
}
// Pull out some useful properties from Coral.
const { id, now, tenant, cache, logger } = req.coral;
// Pull out some useful properties from Coral.
const { id, now, tenant, cache, logger } = req.coral;
if (!cache) {
throw new Error("cache was not set");
}
if (!cache) {
throw new Error("cache was not set");
}
if (!tenant) {
throw new Error("tenant was not set");
}
if (!tenant) {
throw new Error("tenant was not set");
}
// Create some new options to store the tenant context details inside.
const opts: TenantContextOptions = {
...options,
id,
now,
req,
config,
tenant,
logger,
};
// Create some new options to store the tenant context details inside.
const opts: TenantContextOptions = {
...options,
id,
now,
req,
config,
tenant,
logger,
};
// Add the user if there is one.
if (req.user) {
opts.user = req.user;
}
// 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;
}
// 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,
context: new TenantContext(opts),
};
},
metrics
)
return {
schema,
context: new TenantContext(opts),
};
},
metrics
);
+7 -4
View File
@@ -13,6 +13,7 @@ import { HTMLErrorHandler } from "coral-server/app/middleware/error";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import { createPassport } from "coral-server/app/middleware/passport";
import { Config } from "coral-server/config";
import { PersistedQueryCache } from "coral-server/models/queries";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { I18n } from "coral-server/services/i18n";
@@ -28,18 +29,20 @@ import { createRouter } from "./router";
export interface AppOptions {
config: Config;
disableClientRoutes: boolean;
i18n: I18n;
mailerQueue: MailerQueue;
scraperQueue: ScraperQueue;
metrics?: Metrics;
mongo: Db;
parent: Express;
persistedQueryCache: PersistedQueryCache;
persistedQueriesRequired: boolean;
pubsub: RedisPubSub;
redis: AugmentedRedis;
schema: GraphQLSchema;
scraperQueue: ScraperQueue;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
disableClientRoutes: boolean;
metrics?: Metrics;
pubsub: RedisPubSub;
}
/**
@@ -1,97 +0,0 @@
import { RequestHandler, Response } from "express";
import logger from "coral-server/logger";
import { Request } from "coral-server/types/express";
function wrapResponse(req: Request, res: Response) {
// If the request is not an array, or has no elements, we should skip it.
if (!Array.isArray(req.body) || req.body.length === 0) {
return res;
}
// If the request is an array, but it does not have an ID field, then we
// should skip it.
const needsUpgrade = Boolean(typeof req.body[0].id !== "undefined");
if (!needsUpgrade) {
return res;
}
// Grab all the existing ID's.
const ids: string[] = req.body.map(({ id }) => id);
// Save a reference to the old setHeader function.
const setHeader = res.setHeader.bind(res);
// Capture all the headers that are sent to this, in case we need to use it.
const setHeaders: Record<string, any> = {};
res.setHeader = (name: string, value: any) => {
setHeaders[name] = value;
return res;
};
// Save a reference to the old write function.
const write = res.write.bind(res);
// Create a flush function that will be used to flush the response to the
// underlying response.
const flush = (chunk: any, headers: Record<string, any> = setHeaders) => {
for (const name in headers) {
if (!headers.hasOwnProperty(name)) {
continue;
}
setHeader(name, headers[name]);
}
return write(chunk);
};
// Override the response writer to parse the response to determine if it needs
// to be rewritten.
res.write = (chunk: string) => {
try {
// If there is no response, forward it, or if we peek at the first
// character and it's not an array opening, then skip it.
if (chunk.length <= 0 || chunk[0] !== "[") {
return flush(chunk);
}
// Parse the responses, if it's not an array, then skip it.
const responses: object[] | any = JSON.parse(chunk);
if (!Array.isArray(responses) || responses.length === 0) {
return flush(chunk);
}
// If the length of responses do not equal the length of id's collected,
// then skip it.
if (responses.length !== ids.length) {
return flush(chunk);
}
// For each of the responses, zip up their id's into the objects, and
// string concat them together to ensure we get the right request.
const gqlResponse = responses.reduce((body: object[], payload, idx) => {
const id = ids[idx];
body.push({ id, payload });
return body;
}, []);
const response = JSON.stringify(gqlResponse);
return flush(response, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(response, "utf8").toString(),
});
} catch (err) {
logger.error({ err }, "could not parse chunk as JSON");
return flush(chunk);
}
};
return res;
}
export const graphqlBatchMiddleware = (
graphqlRequestHandler: RequestHandler
): RequestHandler => (req: Request, res, next) =>
graphqlRequestHandler(req, wrapResponse(req, res), next);
@@ -0,0 +1,85 @@
import { GraphQLExtension, GraphQLOptions } from "apollo-server-express";
import { Handler } from "express";
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
// 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 { Omit } from "coral-common/types";
import { Config } from "coral-server/config";
import {
ErrorWrappingExtension,
LoggerExtension,
MetricsExtension,
} from "coral-server/graph/common/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(),
];
// Add the metrics extension if provided.
if (metrics) {
extensions.push(
() =>
// 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"> = {
// 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,85 +1,4 @@
import { GraphQLExtension, GraphQLOptions } from "apollo-server-express";
import { Handler } from "express";
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
// 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 { Omit } from "coral-common/types";
import { Config } from "coral-server/config";
import {
ErrorWrappingExtension,
LoggerExtension,
MetricsExtension,
} from "coral-server/graph/common/extensions";
import { Metrics } from "coral-server/services/metrics";
export * from "./batch";
// 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
*/
export const graphqlMiddleware = (
config: Config,
requestOptions: ExpressGraphQLOptionsFunction,
metrics?: Metrics
): Handler => {
const extensions: Array<() => GraphQLExtension> = [
() => new ErrorWrappingExtension(),
() => new LoggerExtension(),
];
// Add the metrics extension if provided.
if (metrics) {
extensions.push(
() =>
// 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"> = {
// 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 as graphqlMiddleware } from "./graphqlMiddleware";
export {
default as persistedQueryMiddleware,
} from "./persistedQueryMiddleware";
@@ -0,0 +1,53 @@
import { AppOptions } from "coral-server/app";
import { RawQueryNotAuthorized } from "coral-server/errors";
import { getPersistedQuery } from "coral-server/graph/tenant/persisted";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import { RequestHandler } from "coral-server/types/express";
type PersistedQueryMiddlewareOptions = Pick<
AppOptions,
"config" | "persistedQueryCache" | "persistedQueriesRequired"
>;
const persistedQueryMiddleware = (
options: PersistedQueryMiddlewareOptions
): RequestHandler => async (req, res, next) => {
try {
if (!req.coral) {
throw new Error("coral was not set");
}
// Pull out some useful properties from Coral.
const { tenant } = req.coral;
if (!tenant) {
throw new Error("tenant was not set");
}
// Handle the payload if it is a persisted query.
const body = req.method === "GET" ? req.query : req.body;
const query = await getPersistedQuery(options.persistedQueryCache, body);
if (!query) {
// Check to see if this is from an ADMIN token which is allowed to run
// un-persisted queries.
if (
options.persistedQueriesRequired &&
(!req.user || req.user.role !== GQLUSER_ROLE.ADMIN)
) {
throw new RawQueryNotAuthorized(
tenant.id,
req.user ? req.user.id : null
);
}
} else {
// The query was found for this operation, replace the query with the one
// provided.
body.query = query.query;
}
return next();
} catch (err) {
return next(err);
}
};
export default persistedQueryMiddleware;
+2
View File
@@ -9,6 +9,7 @@ import {
versionHandler,
} from "coral-server/app/handlers";
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 { errorLogger } from "coral-server/app/middleware/logging";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
@@ -60,6 +61,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
"/graphql",
authenticate(options.passport),
jsonMiddleware,
persistedQueryMiddleware(app),
graphQLHandler(app)
);
+25 -2
View File
@@ -50,6 +50,11 @@ export interface CoralErrorExtensions {
}
export interface CoralErrorContext {
/**
* tenantID is the ID of the tenant that this Error is associated with.
*/
tenantID?: string;
/**
* pub stores information that is used by the translation framework
* to provide context to the error being emitted to pass publicly. Sensitive
@@ -165,8 +170,8 @@ export class CoralError extends VError {
this.status = status;
// Capture the context for the error.
const { pub = {}, pvt = {} } = context;
this.context = { pub, pvt };
const { pub = {}, pvt = {}, tenantID } = context;
this.context = { tenantID, pub, pvt };
// Capture the extension parameters.
this.id = id;
@@ -679,3 +684,21 @@ export class PasswordIncorrect extends CoralError {
});
}
}
export class PersistedQueryNotFound extends CoralError {
constructor(id: string) {
super({
code: ERROR_CODES.PERSISTED_QUERY_NOT_FOUND,
context: { pub: { id } },
});
}
}
export class RawQueryNotAuthorized extends CoralError {
constructor(tenantID: string, userID: string | null) {
super({
code: ERROR_CODES.RAW_QUERY_NOT_AUTHORIZED,
context: { tenantID, pvt: { userID } },
});
}
}
+2
View File
@@ -51,4 +51,6 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
LIVE_UPDATES_DISABLED: "error-liveUpdatesDisabled",
PASSWORD_INCORRECT: "error-passwordIncorrect",
USERNAME_UPDATED_WITHIN_WINDOW: "error-usernameAlreadyUpdated",
PERSISTED_QUERY_NOT_FOUND: "error-persistedQueryNotFound",
RAW_QUERY_NOT_AUTHORIZED: "error-rawQueryNotAuthorized",
};
@@ -0,0 +1,2 @@
export * from "./loader";
export * from "./mapper";
@@ -0,0 +1,56 @@
import fs from "fs-extra";
import { parse } from "graphql";
import path from "path";
import { version } from "coral-common/version";
import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers";
import { PersistedQuery } from "coral-server/models/queries";
export function loadPersistedQueries() {
// Load the files in the persisted queries folder.
const dir = path.join(__dirname, "__generated__");
const files = fs.readdirSync(dir);
// Load each of the persisted queries.
const queries: PersistedQuery[] = [];
for (const filePath of files) {
// Parse the bundle name from the file.
const bundle = filePath.split(".")[0];
// Load the queries from this file.
const fullFilePath = path.join(dir, filePath);
const persistedQueries: Record<string, string> = fs.readJSONSync(
fullFilePath
);
// Go over each of the persisted queries and collect the ID and query to
// merge in.
for (const id in persistedQueries) {
if (!persistedQueries.hasOwnProperty(id)) {
continue;
}
// Grab the actual query out of the file.
const query = persistedQueries[id];
// Parse the file to extract the GraphQL Operation Name.
const { operation, operationName } = getOperationMetadata(parse(query));
if (!operation || !operationName) {
throw new Error(
`operation in ${fullFilePath} with ID ${id} does not have valid operation metadata`
);
}
queries.push({
id,
operation,
operationName,
query,
bundle,
version,
});
}
}
return queries;
}
@@ -0,0 +1,41 @@
import { PersistedQueryNotFound } from "coral-server/errors";
import { PersistedQuery } from "coral-server/models/queries";
interface Payload {
id?: string;
query?: string;
}
interface Cache {
get(id: string): Promise<PersistedQuery | null>;
}
/**
* getPersistedQuery will try to get the persisted query referenced by the
* payload and return it if one exists. If a persisted query is referenced, but
* non is available, it will throw an error.
*
* @param cache the cache to pull the query from
* @param payload the payload that references the query that should be read
*/
export async function getPersistedQuery(
cache: Cache,
payload?: Readonly<Payload>
) {
if (
!payload ||
!payload.id ||
// Persisted queries can either have a query set to `PERSISTED_QUERY` or is
// empty.
!(payload.query === "PERSISTED_QUERY" || payload.query === "")
) {
return null;
}
const query = await cache.get(payload.id);
if (!query) {
throw new PersistedQueryNotFound(payload.id);
}
return query;
}
@@ -9,6 +9,7 @@ import http, { IncomingMessage } from "http";
import {
ConnectionContext,
ExecutionParams,
OperationMessage,
OperationMessagePayload,
SubscriptionServer,
} from "subscriptions-transport-ws";
@@ -25,6 +26,7 @@ import {
CoralError,
InternalError,
LiveUpdatesDisabled,
RawQueryNotAuthorized,
TenantNotFoundError,
} from "coral-server/errors";
import {
@@ -33,6 +35,8 @@ import {
logQuery,
} from "coral-server/graph/common/extensions";
import { getOperationMetadata } from "coral-server/graph/common/extensions/helpers";
import { getPersistedQuery } from "coral-server/graph/tenant/persisted";
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
import logger from "coral-server/logger";
import { userIsStaff } from "coral-server/models/user/helpers";
import { extractTokenFromRequest } from "coral-server/services/jwt";
@@ -205,13 +209,41 @@ export function formatResponse({ metrics }: FormatResponseOptions) {
};
}
export type OnOperationOptions = FormatResponseOptions;
export type OnOperationOptions = FormatResponseOptions &
Pick<AppOptions, "persistedQueryCache" | "persistedQueriesRequired">;
export function onOperation(options: OnOperationOptions) {
return (message: any, params: ExecutionParams<TenantContext>) => {
return async (
message: OperationMessage,
params: ExecutionParams<TenantContext>
) => {
// Attach the response formatter.
params.formatResponse = formatResponse(options);
// Handle the payload if it is a persisted query.
const query = await getPersistedQuery(
options.persistedQueryCache,
message.payload
);
if (!query) {
// Check to see if this is from an ADMIN token which is allowed to run
// un-persisted queries.
if (
options.persistedQueriesRequired &&
(!params.context.user ||
params.context.user.role !== GQLUSER_ROLE.ADMIN)
) {
throw new RawQueryNotAuthorized(
params.context.tenant.id,
params.context.user ? params.context.user.id : null
);
}
} else {
// The query was found for this operation, replace the query with the one
// provided.
params.query = query.query;
}
return params;
};
}
+19
View File
@@ -19,6 +19,7 @@ import { createPubSubClient } from "coral-server/graph/common/subscriptions/pubs
import getTenantSchema from "coral-server/graph/tenant/schema";
import { createSubscriptionServer } from "coral-server/graph/tenant/subscriptions/server";
import logger from "coral-server/logger";
import { PersistedQueryCache } from "coral-server/models/queries";
import { createQueue, TaskQueue } from "coral-server/queue";
import { I18n } from "coral-server/services/i18n";
import { createJWTSigningConfig } from "coral-server/services/jwt";
@@ -260,6 +261,20 @@ class Server {
// Webpack Dev Server.
const disableClientRoutes = this.config.get("disable_client_routes");
// Load and upsert the persisted queries.
const persistedQueryCache = new PersistedQueryCache({ mongo: this.mongo });
// Prime the queries in the database.
await persistedQueryCache.prime();
logger.info(
{ queries: persistedQueryCache.size },
"loaded persisted queries"
);
if (persistedQueryCache.size === 0) {
logger.warn("no persisted queries loaded, did you run `npm run build`?");
}
const options: AppOptions = {
parent,
pubsub: this.pubsub,
@@ -273,6 +288,10 @@ class Server {
mailerQueue: this.tasks.mailer,
scraperQueue: this.tasks.scraper,
disableClientRoutes,
persistedQueryCache,
persistedQueriesRequired:
this.config.get("env") === "production" &&
!this.config.get("enable_graphiql"),
};
// Only enable the metrics server if concurrency is set to 1.
+3 -1
View File
@@ -54,4 +54,6 @@ error-rateLimitExceeded = Rate limit exceeded.
error-inviteTokenExpired = Invite link has expired.
error-inviteRequiresEmailAddresses = Please add an email address to send invitations.
error-passwordIncorrect = Password provided was incorrect.
error-usernameAlreadyUpdated = You may only change your username once every { framework-timeago-time }.
error-usernameAlreadyUpdated = You may only change your username once every { framework-timeago-time }.
error-persistedQueryNotFound = The persisted query with ID { $id } was not found.
error-rawQueryNotAuthorized = You are not authorized to execute this query.
+112
View File
@@ -0,0 +1,112 @@
import DataLoader from "dataloader";
import LRU from "lru-cache";
import { Db } from "mongodb";
import { loadPersistedQueries } from "coral-server/graph/tenant/persisted";
import logger from "coral-server/logger";
import { getQueries, PersistedQuery, primeQueries } from "./queries";
interface PersistedQueryCacheOptions {
mongo: Db;
}
/**
* PersistedQueryCache abstracts the persisted query management.
*/
export class PersistedQueryCache {
private mongo: Db;
private queries: Map<string, PersistedQuery>;
private cache: LRU<string, PersistedQuery>;
private loader: DataLoader<string, PersistedQuery | null>;
constructor(options: PersistedQueryCacheOptions) {
const queries = loadPersistedQueries();
this.mongo = options.mongo;
this.loader = new DataLoader(
(ids: string[]) => getQueries(this.mongo, ids),
{
// Turn off caching as we're using the LRU cache here instead.
cache: false,
}
);
this.queries = new Map();
this.cache = new LRU({
// We'll only retain the amount of queries we have right now so we could
// possibly hold the previous version in memory if need be. Ideally, we'll
// always have the keys we need in memory.
max: queries.length,
dispose: (id, query) => {
logger.warn(
{ queryID: id, queryVersion: query.version },
"cache full, dropping query from cache"
);
},
});
// Insert all the queries into the local query cache.
for (const query of queries) {
this.queries.set(query.id, query);
}
}
public get size() {
return this.queries.size + this.cache.length;
}
/**
* prime will load the local queries into the database so every time that the
* server starts, the queries will be available to other instances.
*/
public async prime() {
if (this.queries.size === 0) {
return;
}
const queries: PersistedQuery[] = [];
for (const query of this.queries.values()) {
queries.push(query);
}
logger.debug({ queries: queries.length }, "priming queries");
await primeQueries(this.mongo, queries);
}
/**
* get will retrieve a given PersistedQuery by ID.
*
* @param id the ID of the persisted query to load
*/
public async get(id: string) {
// Try to get the query from the local query cache.
let query: PersistedQuery | null | undefined = this.queries.get(id);
if (query) {
return query;
}
// Try to get the query from the remote cache.
query = this.cache.get(id);
if (query) {
return query;
}
// Try to get the query from the loader.
query = await this.loader.load(id);
if (query) {
logger.warn(
{ queryID: id, queryVersion: query.version },
"query did not exist in cache, retrieved from MongoDB"
);
// Cache this query in the memory cache.
this.cache.set(query.id, query);
return query;
}
logger.warn({ queryID: id }, "query did not exist in cache or MongoDB");
return null;
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./cache";
export * from "./queries";
+48
View File
@@ -0,0 +1,48 @@
import { Db } from "mongodb";
import { createIndexFactory } from "../helpers/indexing";
function collection(mongo: Db) {
return mongo.collection<Readonly<PersistedQuery>>("queries");
}
export interface PersistedQuery {
id: string;
operation: string;
operationName: string;
query: string;
bundle: string;
version: string;
}
export async function createQueriesIndexes(mongo: Db) {
const createIndex = createIndexFactory(collection(mongo));
// UNIQUE { id }
await createIndex({ id: 1 }, { unique: true });
}
export async function primeQueries(mongo: Db, queries: PersistedQuery[]) {
// Setup persisting these queries.
const bulk = collection(mongo).initializeUnorderedBulkOp({});
// Upsert each query.
for (const query of queries) {
const { id } = query;
// Add to the bulk operation for MongoDB.
bulk
.find({ id })
.upsert()
.replaceOne(query);
}
// Execute the bulk operations.
await bulk.execute();
}
export async function getQueries(mongo: Db, ids: string[]) {
const cursor = await collection(mongo).find({ id: { $in: ids } });
const queries = await cursor.toArray();
return ids.map(id => queries.find(query => query.id === id) || null);
}
@@ -5,6 +5,7 @@ import { createCommentActionIndexes } from "coral-server/models/action/comment";
import { createCommentModerationActionIndexes } from "coral-server/models/action/moderation/comment";
import { createCommentIndexes } from "coral-server/models/comment";
import { createInviteIndexes } from "coral-server/models/invite";
import { createQueriesIndexes } from "coral-server/models/queries";
import {
createStoryCountIndexes,
createStoryIndexes,
@@ -23,6 +24,7 @@ const indexes: Array<[string, IndexCreationFunction]> = [
["stories", createStoryCountIndexes],
["commentActions", createCommentActionIndexes],
["commentModerationActions", createCommentModerationActionIndexes],
["queries", createQueriesIndexes],
];
/**
+1 -1
View File
@@ -142,7 +142,7 @@ declare module "react-relay-network-modern/es" {
export type Requests = RelayRequest[];
export default class RelayRequestBatch {
export class RelayRequestBatch {
public fetchOpts: Partial<FetchOpts>;
public requests: Requests;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "ES2018",
"module": "commonjs",
"allowJs": true,
"noEmit": true,