diff --git a/.changeset/clever-numbers-jog.md b/.changeset/clever-numbers-jog.md new file mode 100644 index 000000000..a1fe6d93f --- /dev/null +++ b/.changeset/clever-numbers-jog.md @@ -0,0 +1,5 @@ +--- +'@pothos/plugin-scope-auth': minor +--- + +[auth] Allow clearing/resetting scope cache in the middle of a request diff --git a/.changeset/rotten-clocks-remember.md b/.changeset/rotten-clocks-remember.md new file mode 100644 index 000000000..0ad528d98 --- /dev/null +++ b/.changeset/rotten-clocks-remember.md @@ -0,0 +1,6 @@ +--- +'@pothos/plugin-prisma': patch +'@pothos/core': patch +--- + +Add delete method to context caches diff --git a/packages/core/src/utils/context-cache.ts b/packages/core/src/utils/context-cache.ts index ea88b7d19..f7b3e065c 100644 --- a/packages/core/src/utils/context-cache.ts +++ b/packages/core/src/utils/context-cache.ts @@ -6,17 +6,17 @@ export function initContextCache() { }; } -export type ContextCache = ( - context: C, - ...args: Args -) => T; +export interface ContextCache { + (context: C, ...args: Args): T; + delete: (context: C) => void; +} export function createContextCache( create: (context: C, ...args: Args) => T, ): ContextCache { const cache = new WeakMap(); - return (context, ...args) => { + const getOrCreate = (context: C, ...args: Args) => { const cacheKey = (context as { [contextCacheSymbol]: object })[contextCacheSymbol] || context; if (cache.has(cacheKey)) { @@ -29,4 +29,12 @@ export function createContextCache { + const cacheKey = (context as { [contextCacheSymbol]: object })[contextCacheSymbol] || context; + + cache.delete(cacheKey); + }; + + return getOrCreate; } diff --git a/packages/plugin-prisma/src/util/get-client.ts b/packages/plugin-prisma/src/util/get-client.ts index 0819e6bbe..f529e9d40 100644 --- a/packages/plugin-prisma/src/util/get-client.ts +++ b/packages/plugin-prisma/src/util/get-client.ts @@ -42,7 +42,7 @@ export interface RuntimeDataModel { } const prismaClientCache = createContextCache( - (builder: PothosSchemaTypes.SchemaBuilder) => + (builder: PothosSchemaTypes.SchemaBuilder) => createContextCache((context: object) => typeof builder.options.prisma.client === 'function' ? builder.options.prisma.client(context) @@ -55,7 +55,9 @@ export function getClient( context: Types['Context'], ): PrismaClient { if (typeof builder.options.prisma.client === 'function') { - return prismaClientCache(builder)(context); + return prismaClientCache(builder as unknown as PothosSchemaTypes.SchemaBuilder)( + context, + ); } return builder.options.prisma.client; diff --git a/packages/plugin-scope-auth/src/index.ts b/packages/plugin-scope-auth/src/index.ts index f325cdbc6..f2a5eeca1 100644 --- a/packages/plugin-scope-auth/src/index.ts +++ b/packages/plugin-scope-auth/src/index.ts @@ -17,6 +17,7 @@ import SchemaBuilder, { SchemaTypes, } from '@pothos/core'; import { isTypeOfHelper } from './is-type-of-helper'; +import RequestCache from './request-cache'; import { resolveHelper } from './resolve-helper'; import { createFieldAuthScopesStep, @@ -27,6 +28,7 @@ import { } from './steps'; import { ResolveStep, TypeAuthScopes, TypeGrantScopes } from './types'; +export { RequestCache }; export * from './errors'; export * from './types'; diff --git a/packages/plugin-scope-auth/src/request-cache.ts b/packages/plugin-scope-auth/src/request-cache.ts index 306d88988..eb700c822 100644 --- a/packages/plugin-scope-auth/src/request-cache.ts +++ b/packages/plugin-scope-auth/src/request-cache.ts @@ -1,6 +1,13 @@ /* eslint-disable @typescript-eslint/promise-function-async */ import { GraphQLResolveInfo } from 'graphql'; -import { isThenable, MaybePromise, Path, PothosValidationError, SchemaTypes } from '@pothos/core'; +import { + createContextCache, + isThenable, + MaybePromise, + Path, + PothosValidationError, + SchemaTypes, +} from '@pothos/core'; import { AuthFailure, AuthScopeFailureType, @@ -10,8 +17,9 @@ import { } from './types'; import { cacheKey, canCache } from './util'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const requestCache = new WeakMap<{}, RequestCache>(); +const contextCache = createContextCache( + (ctx, builder: PothosSchemaTypes.SchemaBuilder) => new RequestCache(builder, ctx), +); export default class RequestCache { builder; @@ -49,12 +57,11 @@ export default class RequestCache { context: T['Context'], builder: PothosSchemaTypes.SchemaBuilder, ): RequestCache { - if (!requestCache.has(context)) { - requestCache.set(context, new RequestCache(builder, context)); - } + return contextCache(context, builder as never) as never; + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return requestCache.get(context)!; + static clearForContext(context: T['Context']): void { + contextCache.delete(context); } getScopes(): MaybePromise> { diff --git a/packages/plugin-scope-auth/tests/__snapshots__/index.test.ts.snap b/packages/plugin-scope-auth/tests/__snapshots__/index.test.ts.snap index 5ecc17871..90b94d49d 100644 --- a/packages/plugin-scope-auth/tests/__snapshots__/index.test.ts.snap +++ b/packages/plugin-scope-auth/tests/__snapshots__/index.test.ts.snap @@ -111,6 +111,7 @@ type Post { } type Query { + ClearCache: ObjForSyncPermFn IfaceBooleanFn(result: Boolean!): IfaceBooleanFn IfaceForAdmin: IfaceForAdmin ObjAdminIface: ObjAdminIface diff --git a/packages/plugin-scope-auth/tests/caching.test.ts b/packages/plugin-scope-auth/tests/caching.test.ts index d3bba5e73..cef9c33c7 100644 --- a/packages/plugin-scope-auth/tests/caching.test.ts +++ b/packages/plugin-scope-auth/tests/caching.test.ts @@ -226,4 +226,40 @@ describe('caching', () => { `); }); }); + + it('clears cache during request', async () => { + const query = gql` + query { + obj: ClearCache { + field + } + } + `; + + const counter = new Counter(); + + const result = await execute({ + schema, + document: query, + contextValue: { + count: counter.count, + user: new User({ + 'x-user-id': '1', + 'x-permissions': 'a', + }), + }, + }); + + expect(counter.counts.get('authScopes')).toBe(2); + + expect(result).toMatchInlineSnapshot(` + { + "data": { + "obj": { + "field": "ok", + }, + }, + } + `); + }); }); diff --git a/packages/plugin-scope-auth/tests/example/builder.ts b/packages/plugin-scope-auth/tests/example/builder.ts index 0dca3fa2e..9b30114c2 100644 --- a/packages/plugin-scope-auth/tests/example/builder.ts +++ b/packages/plugin-scope-auth/tests/example/builder.ts @@ -42,20 +42,28 @@ const builder = new SchemaBuilder<{ authorizeOnSubscribe: true, defaultStrategy: 'all', }, - authScopes: async (context) => ({ - loggedIn: !!context.user, - admin: !!context.user?.roles.includes('admin'), - syncPermission: (perm) => { - context.count?.('syncPermission'); + authScopes: async (context) => { + context.count?.('authScopes'); - return !!context.user?.permissions.includes(perm); - }, - asyncPermission: async (perm) => { - context.count?.('asyncPermission'); + // locally reference use to simulate data loaded in this authScopes fn that depends on incoming + // context data and is not modifiable from resolvers + const { user } = context; - return !!context.user?.permissions.includes(perm); - }, - }), + return { + loggedIn: !!user, + admin: !!user?.roles.includes('admin'), + syncPermission: (perm) => { + context.count?.('syncPermission'); + + return !!user?.permissions.includes(perm); + }, + asyncPermission: async (perm) => { + context.count?.('asyncPermission'); + + return !!user?.permissions.includes(perm); + }, + }; + }, }); export default builder; diff --git a/packages/plugin-scope-auth/tests/example/schema/index.ts b/packages/plugin-scope-auth/tests/example/schema/index.ts index 8e9a494c8..929fb6beb 100644 --- a/packages/plugin-scope-auth/tests/example/schema/index.ts +++ b/packages/plugin-scope-auth/tests/example/schema/index.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/require-await */ import './custom-errors'; import './with-auth'; +import { RequestCache } from '../../../src'; import builder from '../builder'; +import User from '../user'; builder.queryField('currentId', (t) => t.authField({ @@ -847,6 +849,23 @@ builder.queryType({ resolve: () => ({}), }), + ClearCache: t.field({ + type: ObjForSyncPermFn, + nullable: true, + authScopes: { + syncPermission: 'a', + }, + resolve: (parent, args, context) => { + context.user = new User({ + 'x-user-id': '1', + 'x-permissions': 'b', + }); + + RequestCache.clearForContext(context); + + return { permission: 'b' }; + }, + }), }), });