Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Include stacktrace flag #6267

Merged
merged 3 commits into from
Aug 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/real-sheep-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@keystone-next/keystone': minor
'@keystone-next/types': minor
---

Added `config.graphql.debug` option, which can be used to control whether debug information such as stack traces are included in the errors returned by the GraphQL API.
3 changes: 3 additions & 0 deletions docs/pages/docs/apis/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ It has a TypeScript type of `GraphQLConfig`.

Options:

- `debug` (default: `process.env.NODE_ENV !== 'production'`): If `true`, stacktraces from both Apollo errors and Keystone errors will be included in the errors returned from the GraphQL API.
These can be filtered out with `apolloConfig.formatError` if you need to process them, but do not want them returned over the GraphQL API.
- `queryLimits` (default: `undefined`): Allows you to limit the total number of results returned from a query to your GraphQL API.
See also the per-list `graphql.queryLimits` option in the [Schema API](./schema).
- `apolloConfig` (default: `undefined`): Allows you to pass extra options into the `ApolloServer` constructor.
Expand All @@ -287,6 +289,7 @@ Options:
```typescript
export default config({
graphql: {
debug: process.env.NODE_ENV !== 'production',
queryLimits: { maxTotalResults: 100 },
apolloConfig: {
playground: process.env.NODE_ENV !== 'production',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function nextGraphQLAPIRoute(keystoneConfig: KeystoneConfig, prismaClient
graphQLSchema,
createContext: keystone.createContext,
sessionStrategy: initializedKeystoneConfig.session,
apolloConfig: initializedKeystoneConfig.graphql?.apolloConfig,
graphqlConfig: initializedKeystoneConfig.graphql,
connectionPromise: keystone.connect(),
});

Expand Down
2 changes: 1 addition & 1 deletion packages/keystone/src/admin-ui/templates/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const apolloServer = createApolloServerMicro({
graphQLSchema,
createContext,
sessionStrategy: initializedKeystoneConfig.session ? initializedKeystoneConfig.session() : undefined,
apolloConfig: initializedKeystoneConfig.graphql?.apolloConfig,
graphqlConfig: initializedKeystoneConfig.graphql,
connectionPromise: keystone.connect(),
});

Expand Down
8 changes: 2 additions & 6 deletions packages/keystone/src/lib/core/graphql-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@ export const extensionError = (extension: string, things: { error: Error; tag: s
return new ApolloError(
`An error occured while running "${extension}".\n${s}`,
'INTERNAL_SERVER_ERROR',
// Make the original stack traces available in non-production modes.
// TODO: We need to have a way to make these stack traces available
// for logging in production mode.
process.env.NODE_ENV !== 'production'
? { errors: things.map(t => ({ stacktrace: t.error.stack, message: t.error.message })) }
: undefined
// Make the original stack traces available.
{ debug: things.map(t => ({ stacktrace: t.error.stack, message: t.error.message })) }
);
};

Expand Down
44 changes: 34 additions & 10 deletions packages/keystone/src/lib/server/createApolloServer.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import type { IncomingMessage, ServerResponse } from 'http';
import { GraphQLSchema } from 'graphql';
import { GraphQLError, GraphQLSchema } from 'graphql';
import { ApolloServer as ApolloServerMicro } from 'apollo-server-micro';
import { ApolloServer as ApolloServerExpress } from 'apollo-server-express';
import type { Config } from 'apollo-server-express';
import type { CreateContext, SessionStrategy } from '@keystone-next/types';
import type { CreateContext, GraphQLConfig, SessionStrategy } from '@keystone-next/types';
import { createSessionContext } from '../../session';

export const createApolloServerMicro = ({
graphQLSchema,
createContext,
sessionStrategy,
apolloConfig,
graphqlConfig,
connectionPromise,
}: {
graphQLSchema: GraphQLSchema;
createContext: CreateContext;
sessionStrategy?: SessionStrategy<any>;
apolloConfig?: Config;
graphqlConfig?: GraphQLConfig;
connectionPromise: Promise<any>;
}) => {
const context = async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => {
Expand All @@ -28,20 +28,20 @@ export const createApolloServerMicro = ({
req,
});
};
const serverConfig = _createApolloServerConfig({ graphQLSchema, apolloConfig });
const serverConfig = _createApolloServerConfig({ graphQLSchema, graphqlConfig });
return new ApolloServerMicro({ ...serverConfig, context });
};

export const createApolloServerExpress = ({
graphQLSchema,
createContext,
sessionStrategy,
apolloConfig,
graphqlConfig,
}: {
graphQLSchema: GraphQLSchema;
createContext: CreateContext;
sessionStrategy?: SessionStrategy<any>;
apolloConfig?: Config;
graphqlConfig?: GraphQLConfig;
}) => {
const context = async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) =>
createContext({
Expand All @@ -50,18 +50,19 @@ export const createApolloServerExpress = ({
: undefined,
req,
});
const serverConfig = _createApolloServerConfig({ graphQLSchema, apolloConfig });
const serverConfig = _createApolloServerConfig({ graphQLSchema, graphqlConfig });
return new ApolloServerExpress({ ...serverConfig, context });
};

const _createApolloServerConfig = ({
graphQLSchema,
apolloConfig,
graphqlConfig,
}: {
graphQLSchema: GraphQLSchema;
apolloConfig?: Config;
graphqlConfig?: GraphQLConfig;
}) => {
// Playground config, is /api/graphql available?
const apolloConfig = graphqlConfig?.apolloConfig;
const pp = apolloConfig?.playground;
let playground: Config['playground'];
const settings = { 'request.credentials': 'same-origin' };
Expand All @@ -86,6 +87,7 @@ const _createApolloServerConfig = ({
return {
uploads: false,
schema: graphQLSchema,
debug: graphqlConfig?.debug, // If undefined, use Apollo default of NODE_ENV !== 'production'
// FIXME: support for apollo studio tracing
// ...(process.env.ENGINE_API_KEY || process.env.APOLLO_KEY
// ? { tracing: true }
Expand All @@ -97,10 +99,32 @@ const _createApolloServerConfig = ({
// tracing: dev,
// }),
...apolloConfig,
formatError: formatError(graphqlConfig),
// Carefully inject the playground
playground,
// FIXME: Support for file handling configuration
// maxFileSize: 200 * 1024 * 1024,
// maxFiles: 5,
};
};

const formatError = (graphqlConfig: GraphQLConfig | undefined) => {
return (err: GraphQLError) => {
let debug = graphqlConfig?.debug;
if (debug === undefined) {
debug = process.env.NODE_ENV !== 'production';
}

if (!debug && err.extensions) {
// Strip out any `debug` extensions
delete err.extensions.debug;
delete err.extensions.exception;
}

if (graphqlConfig?.apolloConfig?.formatError) {
return graphqlConfig.apolloConfig.formatError(err);
} else {
return err;
}
};
};
16 changes: 10 additions & 6 deletions packages/keystone/src/lib/server/createExpressServer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { Config } from 'apollo-server-express';
import cors, { CorsOptions } from 'cors';
import express from 'express';
import { GraphQLSchema } from 'graphql';
import { graphqlUploadExpress } from 'graphql-upload';
import type { KeystoneConfig, CreateContext, SessionStrategy } from '@keystone-next/types';
import type {
KeystoneConfig,
CreateContext,
SessionStrategy,
GraphQLConfig,
} from '@keystone-next/types';
import { createAdminUIServer } from '../../admin-ui/system';
import { createApolloServerExpress } from './createApolloServer';
import { addHealthCheck } from './addHealthCheck';
Expand All @@ -16,20 +20,20 @@ const addApolloServer = ({
graphQLSchema,
createContext,
sessionStrategy,
apolloConfig,
graphqlConfig,
}: {
server: express.Express;
config: KeystoneConfig;
graphQLSchema: GraphQLSchema;
createContext: CreateContext;
sessionStrategy?: SessionStrategy<any>;
apolloConfig?: Config;
graphqlConfig?: GraphQLConfig;
}) => {
const apolloServer = createApolloServerExpress({
graphQLSchema,
createContext,
sessionStrategy,
apolloConfig,
graphqlConfig,
});

const maxFileSize = config.server?.maxFileSize || DEFAULT_MAX_FILE_SIZE;
Expand Down Expand Up @@ -68,7 +72,7 @@ export const createExpressServer = async (
graphQLSchema,
createContext,
sessionStrategy: config.session,
apolloConfig: config.graphql?.apolloConfig,
graphqlConfig: config.graphql,
});

if (config.ui?.isDisabled) {
Expand Down
34 changes: 34 additions & 0 deletions packages/types/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,40 @@ export type GraphQLConfig = {
* @see https://www.apollographql.com/docs/apollo-server/api/apollo-server/#constructor
*/
apolloConfig?: Config;
/*
* When an error is returned from the GraphQL API, Apollo can include a stacktrace
* indicating where the error occurred. When Keystone is processing mutations, it
* will sometimes captures more than one error at a time, and then group these into
* a single error returned from the GraphQL API. Each of these errors will include
* a stacktrace.
*
* In general both categories of stacktrace are useful for debugging while developing,
* but should not be exposed in production, and this is the default behaviour of Keystone.
*
* You can use the `debug` option to change this behaviour. A use case for this
* would be if you need to send the stacktraces to a log, but do not want to return them
* from the API. In this case you could set `debug: true` and use
* `apolloConfig.formatError` to log the stacktraces and then strip them out before
* returning the error.
*
* ```
* graphql: {
* debug: true,
* apolloConfig: {
* formatError: err => {
* console.error(err);
* delete err.extensions?.errors;
* delete err.extensions?.exception?.errors;
* delete err.extensions?.exception?.stacktrace;
* return err;
* },
* },
* }
* ```
* *
* Default: process.env.NODE_ENV !== 'production'
*/
debug?: boolean;
};

// config.extendGraphqlSchema
Expand Down
14 changes: 9 additions & 5 deletions tests/api-tests/access-control/authed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const expectNoAccess = <N extends string>(
errors: readonly GraphQLError[] | undefined,
name: N
) => {
expectAccessDenied(errors, [{ path: [name] }]);
expectAccessDenied('dev', false, undefined, errors, [{ path: [name] }]);
expect(data?.[name]).toBe(null);
};

Expand Down Expand Up @@ -158,7 +158,9 @@ describe('Authed', () => {
expect(data).toEqual({
authenticatedItem: { id: user.id, yesRead: user.yesRead, noRead: null },
});
expectAccessDenied(errors, [{ path: ['authenticatedItem', 'noRead'] }]);
expectAccessDenied('dev', false, undefined, errors, [
{ path: ['authenticatedItem', 'noRead'] },
]);
});

(['imperative', 'declarative'] as const).forEach(mode => {
Expand Down Expand Up @@ -442,7 +444,7 @@ describe('Authed', () => {
if (mode === 'imperative') {
expectNamedArray(data, errors, multiDeleteMutationName, [validId1, validId2]);
} else {
expectAccessDenied(errors, [
expectAccessDenied('dev', false, undefined, errors, [
{ path: [multiDeleteMutationName, 0] },
{ path: [multiDeleteMutationName, 1] },
]);
Expand All @@ -459,7 +461,9 @@ describe('Authed', () => {
if (mode === 'imperative') {
expectNamedArray(data, errors, multiDeleteMutationName, [validId1, invalidId]);
} else {
expectAccessDenied(errors, [{ path: [multiDeleteMutationName, 1] }]);
expectAccessDenied('dev', false, undefined, errors, [
{ path: [multiDeleteMutationName, 1] },
]);
expect(data).toEqual({ [multiDeleteMutationName]: [{ id: validId1 }, null] });
}
});
Expand All @@ -468,7 +472,7 @@ describe('Authed', () => {
const multiDeleteMutationName = `delete${nameFn[mode](access)}s`;
const query = `mutation { ${multiDeleteMutationName}(where: [{ id: "${FAKE_ID[provider]}" }, { id: "${FAKE_ID_2[provider]}" }]) { id } }`;
const { data, errors } = await context.graphql.raw({ query });
expectAccessDenied(errors, [
expectAccessDenied('dev', false, undefined, errors, [
{ path: [multiDeleteMutationName, 0] },
{ path: [multiDeleteMutationName, 1] },
]);
Expand Down
14 changes: 10 additions & 4 deletions tests/api-tests/access-control/mutations-field-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('Access control - Imperative => static', () => {

// Returns null and throws an error
expect(data).toEqual({ createUser: null });
expectAccessDenied(errors, [{ path: ['createUser'] }]);
expectAccessDenied('dev', false, undefined, errors, [{ path: ['createUser'] }]);

// Only the original user should exist
const _users = await context.lists.User.findMany({ query: 'id name other' });
Expand Down Expand Up @@ -79,7 +79,7 @@ describe('Access control - Imperative => static', () => {

// Returns null and throws an error
expect(data).toEqual({ updateUser: null });
expectAccessDenied(errors, [{ path: ['updateUser'] }]);
expectAccessDenied('dev', false, undefined, errors, [{ path: ['updateUser'] }]);

// User should have its original name
const _users = await context.lists.User.findMany({ query: 'id name other' });
Expand Down Expand Up @@ -118,7 +118,10 @@ describe('Access control - Imperative => static', () => {
});

// The invalid updates should have errors which point to the nulls in their path
expectAccessDenied(errors, [{ path: ['createUsers', 1] }, { path: ['createUsers', 3] }]);
expectAccessDenied('dev', false, undefined, errors, [
{ path: ['createUsers', 1] },
{ path: ['createUsers', 3] },
]);

// Valid users should exist in the database
const users = await context.lists.User.findMany({
Expand Down Expand Up @@ -172,7 +175,10 @@ describe('Access control - Imperative => static', () => {
});

// The invalid updates should have errors which point to the nulls in their path
expectAccessDenied(errors, [{ path: ['updateUsers', 1] }, { path: ['updateUsers', 3] }]);
expectAccessDenied('dev', false, undefined, errors, [
{ path: ['updateUsers', 1] },
{ path: ['updateUsers', 3] },
]);

// All users should still exist in the database
const _users = await context.lists.User.findMany({
Expand Down
14 changes: 10 additions & 4 deletions tests/api-tests/access-control/mutations-list-declarative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('Access control - Imperative => declarative', () => {

// Returns null and throws an error
expect(data).toEqual({ updateUser: null });
expectAccessDenied(errors, [{ path: ['updateUser'] }]);
expectAccessDenied('dev', false, undefined, errors, [{ path: ['updateUser'] }]);

// User should have its original name
const _users = await context.lists.User.findMany({ query: 'id name' });
Expand All @@ -65,7 +65,7 @@ describe('Access control - Imperative => declarative', () => {

// Returns null and throws an error
expect(data).toEqual({ deleteUser: null });
expectAccessDenied(errors, [{ path: ['deleteUser'] }]);
expectAccessDenied('dev', false, undefined, errors, [{ path: ['deleteUser'] }]);

// Bad users should still be in the database.
const _users = await context.lists.User.findMany({ query: 'id name' });
Expand Down Expand Up @@ -120,7 +120,10 @@ describe('Access control - Imperative => declarative', () => {
null,
],
});
expectAccessDenied(errors, [{ path: ['updateUsers', 1] }, { path: ['updateUsers', 3] }]);
expectAccessDenied('dev', false, undefined, errors, [
{ path: ['updateUsers', 1] },
{ path: ['updateUsers', 3] },
]);

// All users should still exist in the database
const _users = await context.lists.User.findMany({
Expand Down Expand Up @@ -161,7 +164,10 @@ describe('Access control - Imperative => declarative', () => {
},
});

expectAccessDenied(errors, [{ path: ['deleteUsers', 1] }, { path: ['deleteUsers', 3] }]);
expectAccessDenied('dev', false, undefined, errors, [
{ path: ['deleteUsers', 1] },
{ path: ['deleteUsers', 3] },
]);

// Valid users are returned, invalid come back as null
// The invalid deletes should have errors which point to the nulls in their path
Expand Down
Loading