mirror of
https://github.com/wassname/talk.git
synced 2026-08-09 12:30:42 +08:00
[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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./cache";
|
||||
export * from "./queries";
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user