-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Enables context creation to be async and capture errors with opt-in logging #1024
Changes from 14 commits
dcac1b0
879a289
97cc12a
2f3eba2
f348355
dc07e92
dfccfa6
2a36f0f
f1c1e79
2f5d243
35420e8
cf48115
b101384
fdd341c
5fa8d88
9522c5a
6be12f1
7a284d5
4225c08
a08594f
fd79cf7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,9 +17,9 @@ import { | |
ExecutionParams, | ||
} from 'subscriptions-transport-ws'; | ||
|
||
import { internalFormatError } from './errors'; | ||
import { formatApolloErrors } from './errors'; | ||
import { GraphQLServerOptions as GraphQLOptions } from './graphqlOptions'; | ||
import { LogFunction } from './runQuery'; | ||
import { LogFunction, LogAction, LogStep } from './logging'; | ||
|
||
import { | ||
Config, | ||
|
@@ -213,7 +213,12 @@ export class ApolloServerBase<Request = RequestInit> { | |
connection.formatResponse = (value: ExecutionResult) => ({ | ||
...value, | ||
errors: | ||
value.errors && value.errors.map(err => internalFormatError(err)), | ||
value.errors && | ||
formatApolloErrors(value.errors, { | ||
formatter: this.requestOptions.formatError, | ||
debug: this.requestOptions.debug, | ||
logFunction: this.requestOptions.logFunction, | ||
}), | ||
}); | ||
let context: Context = this.context ? this.context : { connection }; | ||
|
||
|
@@ -223,8 +228,11 @@ export class ApolloServerBase<Request = RequestInit> { | |
? await this.context({ connection }) | ||
: context; | ||
} catch (e) { | ||
console.error(e); | ||
throw e; | ||
throw formatApolloErrors([e], { | ||
formatter: this.requestOptions.formatError, | ||
debug: this.requestOptions.debug, | ||
logFunction: this.requestOptions.logFunction, | ||
})[0]; | ||
} | ||
|
||
return { ...connection, context }; | ||
|
@@ -270,17 +278,16 @@ export class ApolloServerBase<Request = RequestInit> { | |
async request(request: Request) { | ||
let context: Context = this.context ? this.context : { request }; | ||
|
||
//Differ context resolution to inside of runQuery | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. typo: |
||
context = | ||
typeof this.context === 'function' | ||
? await this.context({ req: request }) | ||
? async () => this.context({ req: request }) | ||
: context; | ||
|
||
return { | ||
schema: this.schema, | ||
tracing: Boolean(this.engineEnabled), | ||
cacheControl: Boolean(this.engineEnabled), | ||
formatError: (e: GraphQLError) => | ||
internalFormatError(e, this.requestOptions.debug), | ||
context, | ||
// allow overrides from options | ||
...this.requestOptions, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
import { GraphQLError } from 'graphql'; | ||
import { LogStep, LogAction, LogMessage, LogFunction } from './logging'; | ||
|
||
export class ApolloError extends Error { | ||
public extensions; | ||
|
@@ -8,14 +9,20 @@ export class ApolloError extends Error { | |
properties?: Record<string, any>, | ||
) { | ||
super(message); | ||
this.extensions = { ...properties, code }; | ||
|
||
if (properties) { | ||
Object.keys(properties).forEach(key => { | ||
this[key] = properties[key]; | ||
}); | ||
} | ||
|
||
//extensions are flattened to be included in the root of GraphQLError's, so | ||
//don't add properties to extensions | ||
this.extensions = { code }; | ||
} | ||
} | ||
|
||
export function internalFormatError( | ||
error: GraphQLError, | ||
debug: boolean = false, | ||
) { | ||
export function enrichError(error: GraphQLError, debug: boolean = false) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this need to be |
||
const expanded: GraphQLError = { | ||
message: error.message, | ||
path: error.path, | ||
|
@@ -95,7 +102,7 @@ export function fromGraphQLError(error: GraphQLError, options?: ErrorOptions) { | |
//copy the original error, while keeping all values non-enumerable, so they | ||
//are not printed unless directly referenced | ||
Object.defineProperty(copy, 'originalError', { value: {} }); | ||
Reflect.ownKeys(error).forEach(key => { | ||
Object.getOwnPropertyNames(error).forEach(key => { | ||
Object.defineProperty(copy.originalError, key, { value: error[key] }); | ||
}); | ||
|
||
|
@@ -133,3 +140,40 @@ export class ForbiddenError extends ApolloError { | |
super(message, 'FORBIDDEN'); | ||
} | ||
} | ||
|
||
export function formatApolloErrors( | ||
errors: Array<Error>, | ||
options?: { | ||
formatter?: Function; | ||
logFunction?: LogFunction; | ||
debug?: boolean; | ||
}, | ||
): Array<Error> { | ||
const { formatter, debug, logFunction } = options; | ||
return errors.map(error => enrichError(error, debug)).map(error => { | ||
if (formatter !== undefined) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than having this larger if (!formatter) {
return error;
}
// Remainder of existing logic, indented by two less spaces.
try {
// ... ? By hoisting that logic, it allows the reader of this code to focus on the remaining functionality, rather than needing to keep in mind that we're still inside a conditional block (the |
||
try { | ||
return formatter(error); | ||
} catch (err) { | ||
logFunction({ | ||
action: LogAction.cleanup, | ||
step: LogStep.status, | ||
data: err, | ||
key: 'error', | ||
}); | ||
|
||
if (debug) { | ||
return enrichError(err, debug); | ||
} else { | ||
//obscure error | ||
const newError: GraphQLError = fromGraphQLError( | ||
new GraphQLError('Internal server error'), | ||
); | ||
return enrichError(newError, debug); | ||
} | ||
} | ||
} else { | ||
return error; | ||
} | ||
}) as Array<Error>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
export enum LogAction { | ||
request, | ||
parse, | ||
validation, | ||
execute, | ||
setup, | ||
cleanup, | ||
} | ||
|
||
export enum LogStep { | ||
start, | ||
end, | ||
status, | ||
} | ||
|
||
export interface LogMessage { | ||
action: LogAction; | ||
step: LogStep; | ||
key?: string; | ||
data?: any; | ||
} | ||
|
||
export interface LogFunction { | ||
(message: LogMessage); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,12 +4,12 @@ import { | |
default as GraphQLOptions, | ||
resolveGraphqlOptions, | ||
} from './graphqlOptions'; | ||
import { internalFormatError } from './errors'; | ||
import { formatApolloErrors } from './errors'; | ||
|
||
export interface HttpQueryRequest { | ||
method: string; | ||
query: Record<string, any>; | ||
options: GraphQLOptions | Function; | ||
options: GraphQLOptions | (() => Promise<GraphQLOptions> | GraphQLOptions); | ||
} | ||
|
||
export class HttpQueryError extends Error { | ||
|
@@ -42,20 +42,31 @@ export async function runHttpQuery( | |
): Promise<string> { | ||
let isGetRequest: boolean = false; | ||
let optionsObject: GraphQLOptions; | ||
const debugDefault = | ||
process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test'; | ||
|
||
try { | ||
optionsObject = await resolveGraphqlOptions( | ||
request.options, | ||
...handlerArguments, | ||
); | ||
} catch (e) { | ||
throw new HttpQueryError(500, e.message); | ||
// The options can be generated asynchronously, so we don't have access to | ||
// the normal options provided by the user, such as: formatError, | ||
// logFunction, debug. Therefore, we need to do some unnatural things, such | ||
// as use NODE_ENV to determine the debug settings | ||
e.message = `Invalid options provided to ApolloServer: ${e.message}`; | ||
throw new HttpQueryError( | ||
500, | ||
JSON.stringify({ | ||
errors: formatApolloErrors([e], { debug: debugDefault }), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This error will return when the options to runHttpQuery are wrapped in a function, which throws an error. Currently this will only get called when the old middleware creation( This line makes me nervous, since a production server that does not use a NODE_ENV of 'production' or 'test' will return a stacktrace to the client. A stacktrace is extremely informative for server creators to debug their systems. The other option would be to log the error in the console, which is non-ideal, since logging should be opt-in. The unfortunate part is that the user provided logging function is not resolved yet when the error occurs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I appreciate you raising this concern here explicitly. Is there any way we can await the resolution of the logging function prior to throwing? |
||
}), | ||
true, | ||
{ | ||
'Content-Type': 'application/json', | ||
}, | ||
); | ||
} | ||
const formatErrorFn = optionsObject.formatError | ||
? error => optionsObject.formatError(internalFormatError(error)) | ||
: internalFormatError; | ||
const debugDefault = | ||
process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test'; | ||
const debug = | ||
optionsObject.debug !== undefined ? optionsObject.debug : debugDefault; | ||
let requestPayload; | ||
|
@@ -99,7 +110,7 @@ export async function runHttpQuery( | |
requestPayload = [requestPayload]; | ||
} | ||
|
||
const requests: Array<ExecutionResult> = requestPayload.map(requestParams => { | ||
const requests = requestPayload.map(async requestParams => { | ||
try { | ||
let query = requestParams.query; | ||
let extensions = requestParams.extensions; | ||
|
@@ -179,9 +190,30 @@ export async function runHttpQuery( | |
} | ||
} | ||
|
||
let context = optionsObject.context || {}; | ||
if (typeof context === 'function') { | ||
context = context(); | ||
let context = optionsObject.context; | ||
if (!context) { | ||
//appease typescript compiler, otherwise could use || {} | ||
context = {}; | ||
} else if (typeof context === 'function') { | ||
try { | ||
context = await context(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So isn't this a backwards-incompatible change? Doesn't this break non-async context functions? I mean that's OK because this is 2.0 I guess, but let's not bury the lede here... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. await unwraps a Promise if it's there. If the value on the rhs of https://github.com/apollographql/apollo-server/pull/1024/files/4225c08b10c90dad5c0acc0b27fc72f10482ad12#diff-5102db46bcc7f6cc0c59149e9cc375d3R26 is the type addition to the context https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await |
||
} catch (e) { | ||
e.message = `Context creation failed: ${e.message}`; | ||
throw new HttpQueryError( | ||
500, | ||
JSON.stringify({ | ||
errors: formatApolloErrors([e], { | ||
formatter: optionsObject.formatError, | ||
debug, | ||
logFunction: optionsObject.logFunction, | ||
}), | ||
}), | ||
true, | ||
{ | ||
'Content-Type': 'application/json', | ||
}, | ||
); | ||
} | ||
} else if (isBatch) { | ||
context = Object.assign( | ||
Object.create(Object.getPrototypeOf(context)), | ||
|
@@ -198,7 +230,7 @@ export async function runHttpQuery( | |
operationName: operationName, | ||
logFunction: optionsObject.logFunction, | ||
validationRules: optionsObject.validationRules, | ||
formatError: formatErrorFn, | ||
formatError: optionsObject.formatError, | ||
formatResponse: optionsObject.formatResponse, | ||
fieldResolver: optionsObject.fieldResolver, | ||
debug: optionsObject.debug, | ||
|
@@ -218,9 +250,16 @@ export async function runHttpQuery( | |
return Promise.reject(e); | ||
} | ||
|
||
return Promise.resolve({ errors: [formatErrorFn(e)] }); | ||
return Promise.resolve({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
errors: formatApolloErrors([e], { | ||
formatter: optionsObject.formatError, | ||
debug, | ||
logFunction: optionsObject.logFunction, | ||
}), | ||
}); | ||
} | ||
}); | ||
}) as Array<Promise<ExecutionResult>>; | ||
|
||
const responses = await Promise.all(requests); | ||
|
||
if (!isBatch) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
await
itself has been removed, does this need to be anasync
function still?