Skip to content

Commit

Permalink
feat(server): For dynamic usage, context option can be a function t…
Browse files Browse the repository at this point in the history
…oo (enisdenjo#46)

* feat: context with func signature

* style: type safety with `GraphQLExecutionContextValue`

* test: use context option

* docs: slim down custom args recipe

* docs: custom graphql context value recipe

* docs: unnecessary "graphql" [skip ci]

* test: prefer `onSubscribe` even with `context` option

* docs: onSubscribe docs

* docs: generate [skip ci]

* docs: a bit more explanation [skip ci]

* fix: allow non-undefined, nullish, context values

* fix: inject roots first
  • Loading branch information
enisdenjo authored Nov 3, 2020
1 parent 222a796 commit 149b582
Show file tree
Hide file tree
Showing 5 changed files with 168 additions and 17 deletions.
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,16 +490,18 @@ server.listen(443);
</details>

<details>
<summary>Server usage with custom static GraphQL arguments</summary>
<summary>Server usage with custom context value</summary>

```typescript
import { validate, execute, subscribe } from 'graphql';
import { createServer } from 'graphql-ws';
import { schema, roots, getStaticContext } from 'my-graphql';
import { schema, roots, getDynamicContext } from 'my-graphql';

createServer(
{
context: getStaticContext(),
context: (ctx, msg, args) => {
return getDynamicContext(ctx, msg, args);
}, // or static context by supplying the value direcly
schema,
roots,
execute,
Expand All @@ -515,12 +517,12 @@ createServer(
</details>

<details>
<summary>Server usage with custom dynamic GraphQL arguments and validation</summary>
<summary>Server usage with custom execution arguments and validation</summary>

```typescript
import { parse, validate, execute, subscribe } from 'graphql';
import { createServer } from 'graphql-ws';
import { schema, getDynamicContext, myValidationRules } from 'my-graphql';
import { schema, myValidationRules } from 'my-graphql';

createServer(
{
Expand All @@ -529,7 +531,6 @@ createServer(
onSubscribe: (ctx, msg) => {
const args = {
schema,
contextValue: getDynamicContext(ctx, msg),
operationName: msg.payload.operationName,
document: parse(msg.payload.query),
variableValues: msg.payload.variables,
Expand Down
18 changes: 13 additions & 5 deletions docs/interfaces/_server_.serveroptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,20 @@ ___

### context

`Optional` **context**: unknown
`Optional` **context**: [GraphQLExecutionContextValue](../modules/_server_.md#graphqlexecutioncontextvalue) \| (ctx: [Context](_server_.context.md), message: [SubscribeMessage](_message_.subscribemessage.md), args: ExecutionArgs) => [GraphQLExecutionContextValue](../modules/_server_.md#graphqlexecutioncontextvalue)

A value which is provided to every resolver and holds
important contextual information like the currently
logged in user, or access to a database.

If you return from the `onSubscribe` callback, this
context value will NOT be injected. You should add it
in the returned `ExecutionArgs` from the callback.
If you return from `onSubscribe`, and the returned value is
missing the `contextValue` field, this context will be used
instead.

If you use the function signature, the final execution arguments
will be passed in (also the returned value from `onSubscribe`).
Since the context is injected on every subscribe, the `SubscribeMessage`
with the regular `Context` will be passed in through the arguments too.

___

Expand Down Expand Up @@ -199,7 +204,10 @@ If you return `ExecutionArgs` from the callback,
it will be used instead of trying to build one
internally. In this case, you are responsible
for providing a ready set of arguments which will
be directly plugged in the operation execution.
be directly plugged in the operation execution. Beware,
the `context` server option is an exception. Only if you
dont provide a context alongside the returned value
here, the `context` server option will be used instead.

To report GraphQL errors simply return an array
of them from the callback, they will be reported
Expand Down
14 changes: 14 additions & 0 deletions docs/modules/_server_.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

### Type aliases

* [GraphQLExecutionContextValue](_server_.md#graphqlexecutioncontextvalue)
* [OperationResult](_server_.md#operationresult)

### Functions
Expand All @@ -22,6 +23,19 @@

## Type aliases

### GraphQLExecutionContextValue

Ƭ **GraphQLExecutionContextValue**: object \| symbol \| number \| string \| boolean \| null \| undefined

A concrete GraphQL execution context value type.

Mainly used because TypeScript collapes unions
with `any` or `unknown` to `any` or `unknown`. So,
we use a custom type to allow definitions such as
the `context` server option.

___

### OperationResult

Ƭ **OperationResult**: Promise\<AsyncIterableIterator\<ExecutionResult> \| ExecutionResult> \| AsyncIterableIterator\<ExecutionResult> \| ExecutionResult
Expand Down
51 changes: 45 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ export type OperationResult =
| AsyncIterableIterator<ExecutionResult>
| ExecutionResult;

/**
* A concrete GraphQL execution context value type.
*
* Mainly used because TypeScript collapes unions
* with `any` or `unknown` to `any` or `unknown`. So,
* we use a custom type to allow definitions such as
* the `context` server option.
*/
export type GraphQLExecutionContextValue =
// eslint-disable-next-line @typescript-eslint/ban-types
| object // you can literally pass "any" JS object as the context value
| symbol
| number
| string
| boolean
| null;

export interface ServerOptions {
/**
* The GraphQL schema on which the operations
Expand All @@ -58,11 +75,22 @@ export interface ServerOptions {
* important contextual information like the currently
* logged in user, or access to a database.
*
* If you return from the `onSubscribe` callback, this
* context value will NOT be injected. You should add it
* in the returned `ExecutionArgs` from the callback.
* If you return from `onSubscribe`, and the returned value is
* missing the `contextValue` field, this context will be used
* instead.
*
* If you use the function signature, the final execution arguments
* will be passed in (also the returned value from `onSubscribe`).
* Since the context is injected on every subscribe, the `SubscribeMessage`
* with the regular `Context` will be passed in through the arguments too.
*/
context?: unknown;
context?:
| GraphQLExecutionContextValue
| ((
ctx: Context,
message: SubscribeMessage,
args: ExecutionArgs,
) => GraphQLExecutionContextValue);
/**
* The GraphQL root fields or resolvers to go
* alongside the schema. Learn more about them
Expand Down Expand Up @@ -149,7 +177,10 @@ export interface ServerOptions {
* it will be used instead of trying to build one
* internally. In this case, you are responsible
* for providing a ready set of arguments which will
* be directly plugged in the operation execution.
* be directly plugged in the operation execution. Beware,
* the `context` server option is an exception. Only if you
* dont provide a context alongside the returned value
* here, the `context` server option will be used instead.
*
* To report GraphQL errors simply return an array
* of them from the callback, they will be reported
Expand Down Expand Up @@ -538,7 +569,6 @@ export function createServer(
const { operationName, query, variables } = message.payload;
const document = typeof query === 'string' ? parse(query) : query;
execArgs = {
contextValue: context,
schema,
operationName,
document,
Expand Down Expand Up @@ -569,6 +599,15 @@ export function createServer(
execArgs.rootValue = roots?.[operationAST.operation];
}

// inject the context, if provided, before the operation.
// but, only if the `onSubscribe` didnt provide one already
if (context !== undefined && !execArgs.contextValue) {
execArgs.contextValue =
typeof context === 'function'
? context(ctx, message, execArgs)
: context;
}

// the execution arguments have been prepared
// perform the operation and act accordingly
let operationResult;
Expand Down
89 changes: 89 additions & 0 deletions src/tests/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,95 @@ it('should pass in the context value from the config', async () => {
expect(subscribeFn.mock.calls[0][0].contextValue).toBe(context);
});

it('should pass the `onSubscribe` exec args to the `context` option and use it', async (done) => {
const context = {};
const execArgs = {
// no context here
schema,
document: parse(`query { getValue }`),
};

const { url } = await startTServer({
onSubscribe: () => {
return execArgs;
},
context: (_ctx, _msg, args) => {
expect(args).toBe(args); // from `onSubscribe`
return context; // will be injected
},
execute: (args) => {
expect(args).toBe(execArgs); // from `onSubscribe`
expect(args.contextValue).toBe(context); // injected by `context`
done();
return execute(args);
},
subscribe,
});

const client = await createTClient(url);
client.ws.send(
stringifyMessage<MessageType.ConnectionInit>({
type: MessageType.ConnectionInit,
}),
);
await client.waitForMessage(({ data }) => {
expect(parseMessage(data).type).toBe(MessageType.ConnectionAck);
});

client.ws.send(
stringifyMessage<MessageType.Subscribe>({
id: '1',
type: MessageType.Subscribe,
payload: {
query: `{ getValue }`,
},
}),
);
});

it('should prefer the `onSubscribe` context value even if `context` option is set', async (done) => {
const context = 'not-me';
const execArgs = {
contextValue: 'me-me', // my custom context
schema,
document: parse(`query { getValue }`),
};

const { url } = await startTServer({
onSubscribe: () => {
return execArgs;
},
context, // should be ignored because there is one in `execArgs`
execute: (args) => {
expect(args).toBe(execArgs); // from `onSubscribe`
expect(args.contextValue).not.toBe(context); // from `onSubscribe`
done();
return execute(args);
},
subscribe,
});

const client = await createTClient(url);
client.ws.send(
stringifyMessage<MessageType.ConnectionInit>({
type: MessageType.ConnectionInit,
}),
);
await client.waitForMessage(({ data }) => {
expect(parseMessage(data).type).toBe(MessageType.ConnectionAck);
});

client.ws.send(
stringifyMessage<MessageType.Subscribe>({
id: '1',
type: MessageType.Subscribe,
payload: {
query: `{ getValue }`,
},
}),
);
});

describe('Connect', () => {
it('should refuse connection and close socket if returning `false`', async () => {
const { url } = await startTServer({
Expand Down

0 comments on commit 149b582

Please sign in to comment.