Skip to content

Commit

Permalink
Make errors configurable to handle stacktraces
Browse files Browse the repository at this point in the history
  • Loading branch information
timleslie committed Aug 9, 2021
1 parent 1374c3f commit 5aab194
Show file tree
Hide file tree
Showing 24 changed files with 817 additions and 639 deletions.
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
---

Add a `config.graphql.debug` option, which can be used to control with debug information such as stack traces are include 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
10 changes: 8 additions & 2 deletions packages/keystone/src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ExitError } from './scripts/utils';
import { initialiseLists } from './lib/core/types-for-lists';
import { printPrismaSchema } from './lib/core/prisma-schema';
import { getDBProvider } from './lib/createSystem';
import { graphQlErrors } from './lib/core/graphql-errors';

export function getSchemaPaths(cwd: string) {
return {
Expand All @@ -27,7 +28,8 @@ export async function getCommittedArtifacts(
graphQLSchema: GraphQLSchema,
config: KeystoneConfig
): Promise<CommittedArtifacts> {
const lists = initialiseLists(config.lists, getDBProvider(config.db));
const errors = graphQlErrors(config.graphql?.debug);
const lists = initialiseLists(config.lists, errors, getDBProvider(config.db));
const prismaSchema = printPrismaSchema(
lists,
getDBProvider(config.db),
Expand Down Expand Up @@ -175,7 +177,11 @@ export async function generateNodeModulesArtifacts(
config: KeystoneConfig,
cwd: string
) {
const lists = initialiseLists(config.lists, getDBProvider(config.db));
const lists = initialiseLists(
config.lists,
graphQlErrors(config.graphql?.debug),
getDBProvider(config.db)
);

const printedSchema = printSchema(graphQLSchema);
const dotKeystoneDir = path.join(cwd, 'node_modules/.keystone');
Expand Down
49 changes: 26 additions & 23 deletions packages/keystone/src/lib/core/graphql-errors.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import { ApolloError } from 'apollo-server-errors';

export const accessDeniedError = () => new ApolloError('You do not have access to this resource');

export const validationFailureError = (messages: string[]) => {
const s = messages.map(m => ` - ${m}`).join('\n');
return new ApolloError(`You provided invalid data for this operation.\n${s}`);
};

export const extensionError = (extension: string, things: { error: Error; tag: string }[]) => {
const s = things.map(t => ` - ${t.tag}: ${t.error.message}`).join('\n');
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
);
export type KeystoneErrors = {
accessDeniedError: any;
validationFailureError: any;
extensionError: any;
limitsExceededError: any;
};

// FIXME: In an upcoming PR we will use these args to construct a better
// error message, so leaving the, here for now. - TL
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const limitsExceededError = (args: { type: string; limit: number; list: string }) =>
new ApolloError('Your request exceeded server limits');
export const graphQlErrors = (debug: boolean | undefined): KeystoneErrors => ({
accessDeniedError: () => new ApolloError('You do not have access to this resource'),
validationFailureError: (messages: string[]) => {
const s = messages.map(m => ` - ${m}`).join('\n');
return new ApolloError(`You provided invalid data for this operation.\n${s}`);
},
extensionError: (extension: string, things: { error: Error; tag: string }[]) => {
const s = things.map(t => ` - ${t.tag}: ${t.error.message}`).join('\n');
return new ApolloError(
`An error occured while running "${extension}".\n${s}`,
'INTERNAL_SERVER_ERROR',
// Make the original stack traces available in non-production modes.
(debug === undefined ? process.env.NODE_ENV !== 'production' : debug)
? { debug: things.map(t => ({ stacktrace: t.error.stack, message: t.error.message })) }
: undefined
);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
limitsExceededError: (args: { type: string; limit: number; list: string }) =>
new ApolloError('Your request exceeded server limits'),
});
6 changes: 4 additions & 2 deletions packages/keystone/src/lib/core/graphql-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@ import { InitialisedList } from './types-for-lists';

import { getMutationsForList } from './mutations';
import { getQueriesForList } from './queries';
import { KeystoneErrors } from './graphql-errors';

export function getGraphQLSchema(
lists: Record<string, InitialisedList>,
errors: KeystoneErrors,
provider: DatabaseProvider
) {
const query = schema.object()({
name: 'Query',
fields: Object.assign({}, ...Object.values(lists).map(list => getQueriesForList(list))),
fields: Object.assign({}, ...Object.values(lists).map(list => getQueriesForList(list, errors))),
});

const updateManyByList: Record<string, schema.InputObjectType<any>> = {};
Expand All @@ -21,7 +23,7 @@ export function getGraphQLSchema(
fields: Object.assign(
{},
...Object.values(lists).map(list => {
const { mutations, updateManyInput } = getMutationsForList(list, provider);
const { mutations, updateManyInput } = getMutationsForList(list, errors, provider);
updateManyByList[list.listKey] = updateManyInput;
return mutations;
})
Expand Down
32 changes: 23 additions & 9 deletions packages/keystone/src/lib/core/mutations/access-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
validateFieldAccessControl,
validateNonCreateListAccessControl,
} from '../access-control';
import { accessDeniedError } from '../graphql-errors';
import { KeystoneErrors } from '../graphql-errors';
import { mapUniqueWhereToWhere } from '../queries/resolvers';
import { InitialisedList } from '../types-for-lists';
import { runWithPrisma } from '../utils';
Expand All @@ -18,18 +18,24 @@ import {
export async function getAccessControlledItemForDelete(
list: InitialisedList,
context: KeystoneContext,
errors: KeystoneErrors,
uniqueInput: UniqueInputFilter,
uniqueWhere: UniquePrismaFilter
): Promise<ItemRootValue> {
const itemId = await getStringifiedItemIdFromUniqueWhereInput(uniqueInput, list.listKey, context);
const itemId = await getStringifiedItemIdFromUniqueWhereInput(
uniqueInput,
list.listKey,
context,
errors
);

// List access: pass 1
const access = await validateNonCreateListAccessControl({
access: list.access.delete,
args: { context, listKey: list.listKey, operation: 'delete', session: context.session, itemId },
});
if (access === false) {
throw accessDeniedError();
throw errors.accessDeniedError();
}

// List access: pass 2
Expand All @@ -39,7 +45,7 @@ export async function getAccessControlledItemForDelete(
}
const item = await runWithPrisma(context, list, model => model.findFirst({ where }));
if (item === null) {
throw accessDeniedError();
throw errors.accessDeniedError();
}

return item;
Expand All @@ -48,11 +54,17 @@ export async function getAccessControlledItemForDelete(
export async function getAccessControlledItemForUpdate(
list: InitialisedList,
context: KeystoneContext,
errors: KeystoneErrors,
uniqueInput: UniqueInputFilter,
uniqueWhere: UniquePrismaFilter,
update: Record<string, any>
) {
const itemId = await getStringifiedItemIdFromUniqueWhereInput(uniqueInput, list.listKey, context);
const itemId = await getStringifiedItemIdFromUniqueWhereInput(
uniqueInput,
list.listKey,
context,
errors
);
const args = {
context,
itemId,
Expand All @@ -68,7 +80,7 @@ export async function getAccessControlledItemForUpdate(
args,
});
if (accessControl === false) {
throw accessDeniedError();
throw errors.accessDeniedError();
}

// List access: pass 2
Expand All @@ -82,7 +94,7 @@ export async function getAccessControlledItemForUpdate(
})
);
if (!item) {
throw accessDeniedError();
throw errors.accessDeniedError();
}

// Field access
Expand All @@ -97,7 +109,7 @@ export async function getAccessControlledItemForUpdate(
);

if (results.some(canAccess => !canAccess)) {
throw accessDeniedError();
throw errors.accessDeniedError();
}

return item;
Expand All @@ -106,6 +118,7 @@ export async function getAccessControlledItemForUpdate(
export async function applyAccessControlForCreate(
list: InitialisedList,
context: KeystoneContext,
{ accessDeniedError }: KeystoneErrors,
originalInput: Record<string, unknown>
) {
const args = {
Expand Down Expand Up @@ -141,7 +154,8 @@ export async function applyAccessControlForCreate(
async function getStringifiedItemIdFromUniqueWhereInput(
uniqueInput: UniqueInputFilter,
listKey: string,
context: KeystoneContext
context: KeystoneContext,
{ accessDeniedError }: KeystoneErrors
): Promise<string> {
if (uniqueInput.id !== undefined) {
return uniqueInput.id;
Expand Down
Loading

0 comments on commit 5aab194

Please sign in to comment.