graphql-compose / graphql-compose-mongoose

Mongoose model converter to GraphQL types with resolvers for graphql-compose https://github.com/nodkz/graphql-compose
MIT License
707 stars 94 forks source link

Problem with mongooseResolvers generics #339

Open DeveloperRic opened 3 years ago

DeveloperRic commented 3 years ago

Hi, I am working on custom guards around resolvers including findOne, findMany, etc. I have hit a couple of problems:

  1. The type of the arguments (TArgs) aren't exported, and so I cannot enforce consistent typing (for args) on the guarding resolver.
  2. The findMany resolver declares its type as Resolver<TSource = any, TContext = any, TDoc extends Document = any>. This is inconsistent with the return value (the resolver returns TDoc[], not TDoc), and so type enforcement breaks again.

For context, here is my Guard class:

export type GuardType = 'ingress' | 'egress'

export interface GuardInput<TContext, TArgs, TReturn> {
  context: TContext
  args: TArgs
  data?: TReturn
}

export interface GuardOutput<TArgs, TReturn> {
  args?: TArgs
  data?: TReturn | false // set to false to remove all data
}

export abstract class Guard<TContext, TArgs = any, TReturn = any> {

  constructor(
    public type: GuardType
  ) { }

  abstract check(input: GuardInput<TContext, TArgs, TReturn>): Promise<GuardOutput<TArgs, TReturn> | void>
}

export function guardResolver<TSource, TContext, TArgs, TReturn>(
  resolver: Resolver<TSource, TContext, TArgs, TReturn>,
  ...guards: Guard<TContext, TArgs, TReturn>[]
): Resolver<TSource, TContext, TArgs, TReturn> {
  const guardedResolver = (<SchemaComposer<TContext>>schemaComposer).createResolver<TSource, TArgs>({
    name: resolver.name,
    type: resolver.getType(),
    args: resolver.getArgs(),
    resolve: async params => {
      for (const guard of guards.filter(guard => guard.type == 'ingress')) {
        const result = await guard.check({
          args: params.args,
          context: params.context
        })
        if (!result) continue
        if (result.args) params.args = result.args
      }
      let data: TReturn | undefined = await resolver.resolve(params)
      for (const guard of guards.filter(guard => guard.type == 'egress')) {
        const result = await guard.check({
          args: params.args,
          context: params.context,
          data
        })
        if (!result) continue
        if (result.args) params.args = result.args
        if (result.data !== null) { // data is being mutated
          if (result.data === false) data = undefined
          else data = result.data
        }
      }
      return data
    }
  })
  return guardedResolver
}

... and here is an example of how I use it:

export const chatQueries: ObjectTypeComposerFieldConfigMapDefinition<IChat, ResolverContext> = {
  chatById: guardResolver(ChatTC.mongooseResolvers.findById(), new IsOwnChatGuard()),
  chatMany: guardResolver(ChatTC.mongooseResolvers.findMany(), new ContainsOnlyOwnChatsGuard())
}

In the above example, at the moment, TS will raise an error for ContainsOnlyOwnChatsGuard because it expects IChat[] whereas findMany() declares that it will return IChat (despite the fact that it returns IChat[]). console.log of the output is below:

[
  { _id: 123456789, name: 'Chat' },
  { _id: 234567890, name: 'Chat' }
]
POST /graphql 200 147.473 ms - 964

I don't know the extent to which these problems exist in the library, any help is appreciated.

canac commented 3 years ago

I think the more supported way of doing what you're trying to achieve is with resolve wrappers. I used resolver wrappers in my app to ensure that users can only read and write their own models, based on a userId field on every model. It took a while to get the types right, but my code is now type safe. Maybe you can do something similar and find a way to refactor your guardResolver to use beforeRecordMutate and beforeQuery.

DeveloperRic commented 3 years ago

Thanks for the suggestion, I was able to use resolve wrappers in my solution & it made it a lot cleaner 😃. However, the type error still exists. Here is my updated guard function:

export function guardResolver<TSource, TContext, TArgs, TReturn>(
  resolver: Resolver<TSource, TContext, TArgs, TReturn>,
  ...guards: Guard<TContext, TArgs, TReturn>[]
): Resolver<TSource, TContext, TArgs, TReturn> {
  const guardedResolver = resolver.wrapResolve(next => async params => {
    // check ingress guards
    for (const guard of guards.filter(guard => guard.type == 'ingress')) {
      const result = await guard.check({
        args: params.args,
        context: params.context
      })
      if (!result) continue
      if (result.args) params.args = result.args
    }
    // get the data
    let data: TReturn | undefined = await next(params)
    // check egress guards
    for (const guard of guards.filter(guard => guard.type == 'egress')) {
      const result = await guard.check({
        args: params.args,
        context: params.context,
        data
      })
      if (!result) continue
      if (result.args) params.args = result.args
      if (result.data !== null) {
        // data is being mutated
        if (result.data === false) data = undefined
        else data = result.data
      }
    }
    return data
  })
  return guardedResolver
}

This line still raises type errors:

chatMany: guardResolver(ChatTC.mongooseResolvers.findMany(), new ContainsOnlyOwnChatsGuard())

because findMany() incorrectly declares its return type as TDoc instead of TDoc[] and ContainsOnlyOwnChatsGuard expects the type IChat[] (which is consistent with the actual data returned by the findMany resolver)