From b55d425cb74eeb77cbf8513906ad54b4d10374cd Mon Sep 17 00:00:00 2001 From: Tim Leslie Date: Fri, 12 Feb 2021 08:28:09 +1100 Subject: [PATCH] Update KeystoneContext APIs --- .../src/system/createAdminUIServer.ts | 12 +- packages-next/auth/src/index.ts | 2 +- .../keystone/src/lib/createContext.ts | 173 +++++++++++++----- .../keystone/src/lib/createExpressServer.ts | 30 +-- .../keystone/src/scripts/migrate/generate.ts | 2 +- packages-next/keystone/src/scripts/run/dev.ts | 2 +- .../keystone/src/scripts/run/start.ts | 2 +- packages-next/keystone/src/session/index.ts | 48 +---- packages-next/types/src/config/index.ts | 2 +- packages-next/types/src/core.ts | 18 +- packages-next/types/src/session.ts | 8 +- packages/test-utils/src/index.ts | 2 +- 12 files changed, 168 insertions(+), 133 deletions(-) diff --git a/packages-next/admin-ui/src/system/createAdminUIServer.ts b/packages-next/admin-ui/src/system/createAdminUIServer.ts index 66c8d2a5c6f..253bc7c4a52 100644 --- a/packages-next/admin-ui/src/system/createAdminUIServer.ts +++ b/packages-next/admin-ui/src/system/createAdminUIServer.ts @@ -1,14 +1,14 @@ import url from 'url'; import next from 'next'; import express from 'express'; -import type { KeystoneConfig, SessionImplementation, CreateContext } from '@keystone-next/types'; +import type { KeystoneConfig, SessionStrategy, CreateContext } from '@keystone-next/types'; -export const createAdminUIServer = async ( +export const createAdminUIServer = async ( ui: KeystoneConfig['ui'], - createContext: CreateContext, + createContext: CreateContext, dev: boolean, projectAdminPath: string, - sessionImplementation?: SessionImplementation + sessionStrategy?: SessionStrategy ) => { const app = next({ dev, dir: projectAdminPath }); const handle = app.getRequestHandler(); @@ -21,9 +21,7 @@ export const createAdminUIServer = async ( handle(req, res); return; } - const context = createContext({ - sessionContext: await sessionImplementation?.createSessionContext(req, res, createContext), - }); + const context = await createContext({ skipAccessControl: false, req, res, sessionStrategy }); const isValidSession = ui?.isAccessAllowed ? await ui.isAccessAllowed(context) : context.session !== undefined; diff --git a/packages-next/auth/src/index.ts b/packages-next/auth/src/index.ts index b76e9ade3d2..455d5f13341 100644 --- a/packages-next/auth/src/index.ts +++ b/packages-next/auth/src/index.ts @@ -163,7 +163,7 @@ export function createAuth({ } if (!session && initFirstItem) { - const count = await createContext({}).sudo().lists[listKey].count({}); + const count = await (await createContext({})).sudo().lists[listKey].count({}); if (count === 0) { if (pathname !== '/init') { return { diff --git a/packages-next/keystone/src/lib/createContext.ts b/packages-next/keystone/src/lib/createContext.ts index 89bfdf28dc5..70c23b5fe5c 100644 --- a/packages-next/keystone/src/lib/createContext.ts +++ b/packages-next/keystone/src/lib/createContext.ts @@ -1,7 +1,6 @@ -import type { IncomingMessage } from 'http'; import { execute, GraphQLSchema, parse } from 'graphql'; import type { - SessionContext, + CreateContext, KeystoneContext, KeystoneGraphQLAPI, BaseKeystone, @@ -24,15 +23,95 @@ export function makeCreateContext({ getArgsByList[listKey] = getArgsFactory(list, graphQLSchema); } - const createContext = ({ - sessionContext, - skipAccessControl = false, + // This context creation code is somewhat fiddly, because some parts of the + // KeystoneContext object need to reference the object itself! In order to + // make this happen, we first prepare the object (_prepareContext), putting in + // placeholders for those parts which require self-binding. We then use this + // object in _bindToContext to fill in the blanks. + + const _prepareContext = ({ + skipAccessControl, req, - }: { - sessionContext?: SessionContext; - skipAccessControl?: boolean; - req?: IncomingMessage; - } = {}): KeystoneContext => { + }: Parameters>[0]): KeystoneContext => ({ + schemaName: 'public', + ...(skipAccessControl ? skipAccessControlContext : accessControlContext), + totalResults: 0, + keystone, + // Only one of these will be available on any given context + // TODO: Capture that in the type + knex: keystone.adapter.knex, + mongoose: keystone.adapter.mongoose, + prisma: keystone.adapter.prisma, + maxTotalResults: keystone.queryLimits.maxTotalResults, + req, + // Note: These two fields let us use the server-side-graphql-client library. + // We may want to remove them once the updated itemAPI w/ resolveFields is available. + executeGraphQL: undefined, + gqlNames: (listKey: string) => keystone.lists[listKey].gqlNames, + + // These properties need to refer to this object. We will bind them later (see _bindToContext) + session: undefined, + startSession: undefined, + endSession: undefined, + sudo: (() => {}) as KeystoneContext['sudo'], + exitSudo: (() => {}) as KeystoneContext['exitSudo'], + withSession: ((() => {}) as unknown) as KeystoneContext['withSession'], + graphql: {} as KeystoneContext['graphql'], + lists: {} as KeystoneContext['lists'], + }); + + const _bindToContext = ({ + contextToReturn, + skipAccessControl, + req, + res, + sessionStrategy, + session, + }: Parameters>[0] & { + contextToReturn: KeystoneContext; + session?: SessionType; + }) => { + // Bind session + contextToReturn.session = session; + if (sessionStrategy) { + Object.assign(contextToReturn, { + startSession: (data: SessionType) => + sessionStrategy.start({ res: res!, data, context: contextToReturn }), + endSession: () => sessionStrategy.end({ req: req!, res: res!, context: contextToReturn }), + }); + } + + // Bind sudo/leaveSudo/withSession + // We want .sudo()/.leaveSudo()/.withSession() to be a synchronous functions, so rather + // than calling createContext, we follow the same steps, but skip the async session object + // creation, instead passing through the session object we already have, or the paramter + // for .withSession(). + contextToReturn.sudo = () => { + const args = { skipAccessControl: true, req, res, sessionStrategy }; + const contextToReturn = _prepareContext(args); + return _bindToContext({ contextToReturn, ...args, session }); + }; + contextToReturn.exitSudo = () => { + const args = { skipAccessControl: false, req, res, sessionStrategy }; + const contextToReturn = _prepareContext(args); + return _bindToContext({ contextToReturn, ...args, session }); + }; + contextToReturn.withSession = session => { + const args = { skipAccessControl, req, res, sessionStrategy }; + const contextToReturn = _prepareContext(args); + return _bindToContext({ contextToReturn, ...args, session }); + }; + + // Bind items API + for (const [listKey, list] of Object.entries(keystone.lists)) { + contextToReturn.lists[listKey] = itemAPIForList( + list, + contextToReturn, + getArgsByList[listKey] + ); + } + + // Bind graphql API const rawGraphQL: KeystoneGraphQLAPI['raw'] = ({ query, context, variables }) => { if (typeof query === 'string') { query = parse(query); @@ -53,45 +132,47 @@ export function makeCreateContext({ } return result.data as Record; }; - const itemAPI: Record> = {}; - const contextToReturn: KeystoneContext = { - schemaName: 'public', - ...(skipAccessControl ? skipAccessControlContext : accessControlContext), - lists: itemAPI, - totalResults: 0, - keystone, - // Only one of these will be available on any given context - // TODO: Capture that in the type - knex: keystone.adapter.knex, - mongoose: keystone.adapter.mongoose, - prisma: keystone.adapter.prisma, - graphql: { - createContext, - raw: rawGraphQL, - run: runGraphQL, - schema: graphQLSchema, - } as KeystoneGraphQLAPI, - maxTotalResults: keystone.queryLimits.maxTotalResults, - sudo: () => createContext({ sessionContext, skipAccessControl: true, req }), - exitSudo: () => createContext({ sessionContext, skipAccessControl: false, req }), - withSession: session => - createContext({ - sessionContext: { ...sessionContext, session } as SessionContext, - skipAccessControl, - req, - }), - req, - ...sessionContext, - // Note: These two fields let us use the server-side-graphql-client library. - // We may want to remove them once the updated itemAPI w/ resolveFields is available. - executeGraphQL: rawGraphQL, - gqlNames: (listKey: string) => keystone.lists[listKey].gqlNames, + contextToReturn.graphql = { + createContext, + raw: rawGraphQL, + run: runGraphQL, + schema: graphQLSchema, }; - for (const [listKey, list] of Object.entries(keystone.lists)) { - itemAPI[listKey] = itemAPIForList(list, contextToReturn, getArgsByList[listKey]); - } + contextToReturn.executeGraphQL = rawGraphQL; + return contextToReturn; }; + const createContext = async ({ + skipAccessControl = false, + req, + res, + sessionStrategy, + }: Parameters>[0] = {}): Promise => { + const contextToReturn = _prepareContext({ skipAccessControl, req, res, sessionStrategy }); + + // Build session if necessary + const session = sessionStrategy + ? await sessionStrategy.get({ + req: req!, + sudoContext: await createContext({ + skipAccessControl: true, + req, + res, + sessionStrategy: undefined, + }), + }) + : undefined; + + return _bindToContext({ + contextToReturn, + skipAccessControl, + req, + res, + sessionStrategy, + session, + }); + }; + return createContext; } diff --git a/packages-next/keystone/src/lib/createExpressServer.ts b/packages-next/keystone/src/lib/createExpressServer.ts index 790a55ff97a..3378d98509a 100644 --- a/packages-next/keystone/src/lib/createExpressServer.ts +++ b/packages-next/keystone/src/lib/createExpressServer.ts @@ -6,20 +6,19 @@ import { ApolloServer } from 'apollo-server-express'; import { graphqlUploadExpress } from 'graphql-upload'; // @ts-ignore import { formatError } from '@keystonejs/keystone/lib/Keystone/format-error'; -import type { KeystoneConfig, SessionImplementation, CreateContext } from '@keystone-next/types'; +import type { KeystoneConfig, SessionStrategy, CreateContext } from '@keystone-next/types'; import { createAdminUIServer } from '@keystone-next/admin-ui/system'; -import { implementSession } from '../session'; -const addApolloServer = ({ +const addApolloServer = ({ server, graphQLSchema, createContext, - sessionImplementation, + sessionStrategy, }: { server: express.Express; graphQLSchema: GraphQLSchema; - createContext: CreateContext; - sessionImplementation?: SessionImplementation; + createContext: CreateContext; + sessionStrategy?: SessionStrategy; }) => { const apolloServer = new ApolloServer({ uploads: false, @@ -28,10 +27,7 @@ const addApolloServer = ({ playground: { settings: { 'request.credentials': 'same-origin' } }, formatError, // TODO: this needs to be discussed context: async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => - createContext({ - sessionContext: await sessionImplementation?.createSessionContext(req, res, createContext), - req, - }), + createContext({ skipAccessControl: false, req, res, sessionStrategy }), // FIXME: support for apollo studio tracing // ...(process.env.ENGINE_API_KEY || process.env.APOLLO_KEY // ? { tracing: true } @@ -57,7 +53,7 @@ const addApolloServer = ({ export const createExpressServer = async ( config: KeystoneConfig, graphQLSchema: GraphQLSchema, - createContext: CreateContext, + createContext: CreateContext, dev: boolean, projectAdminPath: string ) => { @@ -73,20 +69,14 @@ export const createExpressServer = async ( server.use(cors(corsConfig)); } - const sessionImplementation = config.session ? implementSession(config.session()) : undefined; + const sessionStrategy = config.session ? config.session() : undefined; console.log('✨ Preparing GraphQL Server'); - addApolloServer({ server, graphQLSchema, createContext, sessionImplementation }); + addApolloServer({ server, graphQLSchema, createContext, sessionStrategy }); console.log('✨ Preparing Next.js app'); server.use( - await createAdminUIServer( - config.ui, - createContext, - dev, - projectAdminPath, - sessionImplementation - ) + await createAdminUIServer(config.ui, createContext, dev, projectAdminPath, sessionStrategy) ); return server; diff --git a/packages-next/keystone/src/scripts/migrate/generate.ts b/packages-next/keystone/src/scripts/migrate/generate.ts index a67c7d80e02..319aaa8029d 100644 --- a/packages-next/keystone/src/scripts/migrate/generate.ts +++ b/packages-next/keystone/src/scripts/migrate/generate.ts @@ -19,7 +19,7 @@ export const generate = async ({ dotKeystonePath }: StaticPaths) => { await saveSchemaAndTypes(graphQLSchema, keystone, dotKeystonePath); console.log('✨ Generating migration'); - await keystone.connect({ context: createContext().sudo() }); + await keystone.connect({ context: (await createContext()).sudo() }); await keystone.disconnect(); }; diff --git a/packages-next/keystone/src/scripts/run/dev.ts b/packages-next/keystone/src/scripts/run/dev.ts index 439fba3b5c6..f874d44dccd 100644 --- a/packages-next/keystone/src/scripts/run/dev.ts +++ b/packages-next/keystone/src/scripts/run/dev.ts @@ -35,7 +35,7 @@ export const dev = async ({ dotKeystonePath, projectAdminPath }: StaticPaths, sc await saveSchemaAndTypes(graphQLSchema, keystone, dotKeystonePath); console.log('✨ Connecting to the database'); - await keystone.connect({ context: createContext().sudo() }); + await keystone.connect({ context: (await createContext()).sudo() }); console.log('✨ Generating Admin UI code'); await generateAdminUI(config, graphQLSchema, keystone, projectAdminPath); diff --git a/packages-next/keystone/src/scripts/run/start.ts b/packages-next/keystone/src/scripts/run/start.ts index 240c67bb3e1..63a404fffe0 100644 --- a/packages-next/keystone/src/scripts/run/start.ts +++ b/packages-next/keystone/src/scripts/run/start.ts @@ -16,7 +16,7 @@ export const start = async ({ dotKeystonePath, projectAdminPath }: StaticPaths) const { keystone, graphQLSchema, createContext } = createSystem(config, dotKeystonePath, 'start'); console.log('✨ Connecting to the database'); - await keystone.connect({ context: createContext().sudo() }); + await keystone.connect({ context: (await createContext()).sudo() }); console.log('✨ Creating server'); const server = await createExpressServer( diff --git a/packages-next/keystone/src/session/index.ts b/packages-next/keystone/src/session/index.ts index 949755ace6c..857a35f3126 100644 --- a/packages-next/keystone/src/session/index.ts +++ b/packages-next/keystone/src/session/index.ts @@ -1,14 +1,6 @@ -import { IncomingMessage, ServerResponse } from 'http'; import * as cookie from 'cookie'; import Iron from '@hapi/iron'; -import { - SessionStrategy, - JSONValue, - SessionStoreFunction, - SessionContext, - CreateContext, - SessionImplementation, -} from '@keystone-next/types'; +import { SessionStrategy, JSONValue, SessionStoreFunction } from '@keystone-next/types'; // uid-safe is what express-session uses so let's just use it import { sync as uid } from 'uid-safe'; @@ -73,9 +65,8 @@ export function withItemData( const { get, ...sessionStrategy } = createSession(); return { ...sessionStrategy, - get: async ({ req, createContext }) => { - const session = await get({ req, createContext }); - const sudoContext = createContext({}).sudo(); + get: async ({ req, sudoContext }) => { + const session = await get({ req, sudoContext }); if ( !session || !session.listKey || @@ -181,8 +172,8 @@ export function storedSessions({ return { connect: store.connect, disconnect: store.disconnect, - async get({ req, createContext }) { - let sessionId = await get({ req, createContext }); + async get({ req, sudoContext }) { + let sessionId = await get({ req, sudoContext }); if (typeof sessionId === 'string') { if (!isConnected) { await store.connect?.(); @@ -191,17 +182,17 @@ export function storedSessions({ return store.get(sessionId); } }, - async start({ res, data, createContext }) { + async start({ res, data, context }) { let sessionId = generateSessionId(); if (!isConnected) { await store.connect?.(); isConnected = true; } await store.set(sessionId, data); - return start?.({ res, data: { sessionId }, createContext }) || ''; + return start?.({ res, data: { sessionId }, context }) || ''; }, - async end({ req, res, createContext }) { - let sessionId = await get({ req, createContext }); + async end({ req, res, context }) { + let sessionId = await get({ req, sudoContext: context.sudo() }); if (typeof sessionId === 'string') { if (!isConnected) { await store.connect?.(); @@ -209,27 +200,8 @@ export function storedSessions({ } await store.delete(sessionId); } - await end?.({ req, res, createContext }); + await end?.({ req, res, context }); }, }; }; } - -/** - * This is the function createSystem uses to implement the session strategy provided - */ -export function implementSession(sessionStrategy: SessionStrategy): SessionImplementation { - return { - async createSessionContext( - req: IncomingMessage, - res: ServerResponse, - createContext: CreateContext - ): Promise> { - return { - session: await sessionStrategy.get({ req, createContext }), - startSession: (data: T) => sessionStrategy.start({ res, data, createContext }), - endSession: () => sessionStrategy.end({ req, res, createContext }), - }; - }, - }; -} diff --git a/packages-next/types/src/config/index.ts b/packages-next/types/src/config/index.ts index ebf6050b339..2b31dcaa6be 100644 --- a/packages-next/types/src/config/index.ts +++ b/packages-next/types/src/config/index.ts @@ -81,7 +81,7 @@ export type AdminUIConfig = { req: IncomingMessage; session: any; isValidSession: boolean; - createContext: CreateContext; + createContext: CreateContext; }) => MaybePromise<{ kind: 'redirect'; to: string } | void>; }; diff --git a/packages-next/types/src/core.ts b/packages-next/types/src/core.ts index fd72a628eeb..9fceac89581 100644 --- a/packages-next/types/src/core.ts +++ b/packages-next/types/src/core.ts @@ -3,6 +3,7 @@ import { GraphQLSchema, ExecutionResult, DocumentNode } from 'graphql'; import type { BaseGeneratedListTypes, GqlNames, MaybePromise } from './utils'; import { BaseKeystone } from './base'; +import { SessionStrategy } from './session'; // DatabaseAPIs is used to provide access to the underlying database abstraction through // context and other developer-facing APIs in Keystone, so they can be used easily. @@ -23,19 +24,12 @@ export type FieldDefaultValue = | null | MaybePromise<(args: FieldDefaultValueArgs) => T | null | undefined>; -export type CreateContext = (args: { - sessionContext?: SessionContext; +export type CreateContext = (args: { skipAccessControl?: boolean; req?: IncomingMessage; -}) => KeystoneContext; - -export type SessionImplementation = { - createSessionContext( - req: IncomingMessage, - res: ServerResponse, - createContext: CreateContext - ): Promise>; -}; + res?: ServerResponse; + sessionStrategy?: SessionStrategy; +}) => Promise; export type AccessControlContext = { getListAccessControlForUser: any; // TODO @@ -87,7 +81,7 @@ export type KeystoneGraphQLAPI< // eslint-disable-next-line @typescript-eslint/no-unused-vars KeystoneListsTypeInfo extends Record > = { - createContext: CreateContext; + createContext: CreateContext; schema: GraphQLSchema; run: (args: GraphQLExecutionArguments) => Promise>; diff --git a/packages-next/types/src/session.ts b/packages-next/types/src/session.ts index 33532f57b91..b827b5f895d 100644 --- a/packages-next/types/src/session.ts +++ b/packages-next/types/src/session.ts @@ -1,6 +1,6 @@ import type { JSONValue } from './utils'; import type { ServerResponse, IncomingMessage } from 'http'; -import { CreateContext } from '.'; +import { KeystoneContext } from './core'; export type SessionStrategy = { connect?: () => Promise; @@ -10,19 +10,19 @@ export type SessionStrategy = { start: (args: { res: ServerResponse; data: StoredSessionData | StartSessionData; - createContext: CreateContext; + context: KeystoneContext; }) => Promise; // resets the cookie via res end: (args: { req: IncomingMessage; res: ServerResponse; - createContext: CreateContext; + context: KeystoneContext; }) => Promise; // -- this one is invoked at the start of every request // reads the token, gets the data, returns it get: (args: { req: IncomingMessage; - createContext: CreateContext; + sudoContext: KeystoneContext; }) => Promise; }; diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 5825a0f1554..b532a4045f5 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -66,7 +66,7 @@ async function setupFromConfig({ config = initConfig(config); const { keystone, createContext } = createSystem(config, path.resolve('.keystone'), ''); - return { keystone, context: createContext().sudo() }; + return { keystone, context: (await createContext()).sudo() }; } async function setupServer({