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

feat(schema): add schema.middleware #688

Merged
merged 6 commits into from
Apr 21, 2020
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
101 changes: 101 additions & 0 deletions docs/api/modules/main/exports/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,107 @@ schema.use({
})
```

### `middleware`

Run arbitrary logic before and/or after GraphQL resolvers in your schema. Middleware is run as a first-in-first-out (FIFO) stack. The order of your middleware definitions determines their order in the stack.

##### Signature

```ts
schema.middleware((config: CreateFieldResolverInfo) => MiddlewareFn | undefined)): void
```

`schema.middleware` expect a function with the signature `(root, args, ctx, info, next)` to be returned.

If the current middleware function does not end the request-response cycle, it must call `next` to pass control to the next middleware function, until the actual GraphQL resolver gets called.

> Note: You can skip the creation of a middleware by returning `undefined` instead of a middleware.

##### Example: Simple middlewares

```ts
// graphql.ts
import { schema } from 'nexus'

schema.middleware((_config) => {
return async (root, args, ctx, info, next) => {
console.log('before - middleware 1')
const result = await next(root, args, ctx, info)
console.log('after - middleware 1')
return result
}
})

schema.middleware((_config) => {
return async (root, args, ctx, info, next) => {
console.log('before - middleware 2')
const result = await next(root, args, ctx, info)
console.log('after - middleware 2')
return result
}
})

schema.queryType({
definition(t) {
t.string('hello', () => {
console.log('executing resolver')
return Promise.resolve('world')
})
},
})

/**
* Output
* before - middleware 1
* before - middleware 2
* executing resolver
* after - middleware 2
* after - middleware 1
*/
```

##### Example: Trace resolvers completion time of the `Query` type only

```ts
import { schema } from 'nexus'

schema.middleware((config) => {
if (config.parentTypeConfig.name !== 'Query') {
return
}

return async (root, args, ctx, info, next) => {
const startTimeMs = new Date().valueOf()
const value = await next(root, args, ctx, info)
const endTimeMs = new Date().valueOf()
const resolver = `Query.${config.fieldConfig.name}`
const completionTime = endTimeMs - startTimeMs

ctx.log.info(`Resolver '${resolver}' took ${completionTime} ms`, {
resolver,
completionTime,
})

return value
}
})

schema.queryType({
definition(t) {
t.string('hello', async () => {
// Wait two seconds
await new Promise((res) => setTimeout(res, 2000))
return 'world'
})
},
})

/**
* Output:
* ● server:request Resolver 'Query.hello' took 2001 ms -- resolver: 'Query.hello' time: 2001
*/
```

### Type Glossary

#### `I` `FieldConfig`
Expand Down
4 changes: 0 additions & 4 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,6 @@
padding-top: 1rem !important;
padding-bottom: 1rem !important;
}
.markdown-section pre {
max-height: 40rem;
overflow-y: scroll;
}

.markdown-section pre > code {
font-size: 0.8rem;
Expand Down
4 changes: 2 additions & 2 deletions src/lib/watcher/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,14 +177,14 @@ it('restarts when a file is deleted', async () => {

it('restarts when a file has an error', async () => {
const { bufferedEvents } = await testSimpleCase({
entrypoint: `throw new Error('there is an error')`,
entrypoint: `throw new Error('This is an expected test error')`,
fsUpdate: () => {
ctx.write({ 'entrypoint.ts': `process.stdout.write('error fixed')` })
},
})

expect(bufferedEvents[0].type).toBe('runner_stdio')
expect((bufferedEvents[0] as any).data).toContain('Error: there is an error')
expect((bufferedEvents[0] as any).data).toContain('Error: This is an expected test error')

bufferedEvents.shift()

Expand Down
12 changes: 7 additions & 5 deletions src/runtime/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ export type Settings = {
change(newSetting: SettingsInput): void
}

export type AppState = {
plugins: Plugin.Plugin[]
isWasServerStartCalled: boolean
}

export type InternalApp = App & {
__state: {
plugins: Plugin.Plugin[]
isWasServerStartCalled: boolean
}
__state: AppState
}

/**
Expand All @@ -83,7 +85,7 @@ export function create(): App {
}

const server = Server.create()
const schemaComponent = Schema.create()
const schemaComponent = Schema.create(__state)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, I had being going a direction before where schema would have its own state. Your approach reminds me of a react-ish atomic-state idea. Any particular thoughts here or you just did what first came to you?


const settings: Settings = {
change(newSettings) {
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/schema/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { mapSettingsToNexusSchemaConfig } from './settings'
let schema: ReturnType<typeof create>

beforeEach(() => {
schema = create()
schema = create({ isWasServerStartCalled: false, plugins: [] })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this part feel like a leak of detail?

})

it('defaults to outputs being nullable by default', () => {
Expand Down
42 changes: 37 additions & 5 deletions src/runtime/schema/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as NexusSchema from '@nexus/schema'
import { makeSchemaInternal } from '@nexus/schema/dist/core'
import { CreateFieldResolverInfo, makeSchemaInternal } from '@nexus/schema/dist/core'
import { stripIndent } from 'common-tags'
import { GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql'
import * as HTTP from 'http'
import { runAddToContextExtractorAsWorkerIfPossible } from '../../lib/add-to-context-extractor/add-to-context-extractor'
import * as Layout from '../../lib/layout'
Expand All @@ -10,6 +12,7 @@ import {
writeTypegen,
} from '../../lib/nexus-schema-stateful'
import { RuntimeContributions } from '../../lib/plugin'
import { AppState } from '../app'
import { log } from './logger'
import { changeSettings, mapSettingsToNexusSchemaConfig, SettingsData, SettingsInput } from './settings'

Expand All @@ -26,11 +29,23 @@ export interface Request extends HTTP.IncomingMessage {

export type ContextContributor<Req> = (req: Req) => Record<string, unknown>

type MiddlewareFn = (
source: any,
args: any,
context: NexusSchema.core.GetGen<'context'>,
info: GraphQLResolveInfo,
next: GraphQLFieldResolver<any, any>
) => any

export interface Schema extends NexusSchemaStatefulBuilders {
/**
* todo link to website docs
*/
use: (schemaPlugin: NexusSchema.core.NexusPlugin) => void
/**
* todo link to website docs
*/
middleware: (fn: (config: CreateFieldResolverInfo) => MiddlewareFn | undefined) => void
/**
* todo link to website docs
*/
Expand All @@ -57,7 +72,7 @@ interface SchemaInternal {
public: Schema
}

export function create(): SchemaInternal {
export function create(appState: AppState): SchemaInternal {
const statefulNexusSchema = createNexusSchemaStateful()

const state: SchemaInternal['private']['state'] = {
Expand All @@ -66,18 +81,35 @@ export function create(): SchemaInternal {
contextContributors: [],
}

const middleware: SchemaInternal['public']['middleware'] = (fn) => {
api.public.use(
NexusSchema.plugin({
// TODO: Do we need to expose the name property?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no?

name: 'local-middleware',
onCreateFieldResolver(config) {
return fn(config)
},
})
)
}

const api: SchemaInternal = {
public: {
...statefulNexusSchema.builders,
use(plugin) {
// todo if caleld after app start, raise a warning (dev), error (prod)
// this will require the component having access to some of the
// framework state.
if (appState.isWasServerStartCalled === true) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh this is the key reason to pass down app state, I see. Def. a fair point!

log.warn(stripIndent`
A Nexus Schema plugin was ignored because it was loaded after the server was started
Make sure to call \`schema.use\` before you call \`server.start\`
`)
}

state.schemaPlugins.push(plugin)
},
addToContext(contextContributor) {
state.contextContributors.push(contextContributor)
},
middleware,
},
private: {
state: state,
Expand Down