sikanhe / gqtx

Code-first Typescript GraphQL Server without codegen or metaprogramming
458 stars 13 forks source link

Extensions support #29

Open n1ru4l opened 3 years ago

n1ru4l commented 3 years ago

It would be nice if extensions could be specified for the fields/types. We could make it type-safe by allowing a generic extension type on the createTypesFactory function.

sikanhe commented 3 years ago

Could be a great idea - any rough example what would the API look like?

n1ru4l commented 3 years ago

A quick sketch:


type VisibilityExtension = {
  visibilityAccess?: "public" | "hidden";
}

type LiveQueryExtensions = {
  liveQuery?: {
    buildResourceIdentifier: (source: unknown) => string;
  },
}

type TExtensions = VisibilityExtension & LiveQueryExtensions

const t = createTypesFactory<TContext, TExtensions>();

const Character = t.objectType({
  name: "Character",
  extensions: {
    visibilityAccess: "public"
  },
  fields: () => [
    t.field("name", {
      type: t.NonNull(t.String),
      resolve: (character) => "HI",
    }),
  ],
});

Note extensions are just there for adding metadata to the schema. Actually doing sth with the metadata must be done in user-land. We could however provide some way of at least providing typings for the available extensions.

See https://github.com/graphql/graphql-js/pull/2097

I guess we will hit limits on what we can do. E.g. buildResourceIdentifier would receive the source object of the object type (in the user-land implementation), however, I am not aware of how that could be typed properly in a general way.

n1ru4l commented 3 years ago

I created https://github.com/sikanhe/gqtx/pull/36 for a basic implementation. It does however not use any typing magic, but I guess stuff like that could still be introduced later on.

Ericnr commented 3 years ago

I was asked for some examples of use cases, here are some.

interface TExtensions {
  authorize?: (ctx: TContext) => boolean;
  authenticate?: boolean;
}

const t = createTypesFactory<TContext, TExtensions>({
  extensions: {
    authorize: { // first arg is whatever value passed to authorize, in this case a fn 
      extendObjectTypeResolver: (checkAuth) => async (parent, args, ctx, info) => {
        const isAuthorized = await checkAuth(ctx);
        if (!isAuthorized) throw new AuthorizationError()
      }
    },
    authenticate: {
      extendObjectTypeResolver: (needsAuthentication) => async (parent, args, ctx, info) => {
         const isAuthenticated = await ctx.checkDbForSession(ctx.user.id);
          if (!isAuthenticated) throw new AuthenticationError()
      }
    },
  }
});

const Mutation = t.mutationType({
  fields: [
    t.field('doStuffOnlyAdminShouldDo', {
      type: SomeResponseType,
      args: {
        id: t.arg(t.NonNullInput(t.ID)),
      },
      extensions: {
        authorize: (ctx) => ctx.checkRoles(['ADMIN']),
        authenticate: true,
      },
      resolve: (_, args, ctx) => {
        ...
      },
    }),
  ],
});

Now for a more complicated example, and probably much harder to define with TS

import { fromGlobalId } from 'graphql-relay';
const t = createTypesFactory<TContext, TExtensions>({
  extensions: {
    parse: {
      extendInputType: (parseFn) => async (args, argName) => {
        // mutates the args object before the resolver runs
        // parse function will also validate inputs and throw if invalid
        args[argName] = await parseFn(args[argName]);
      },
    },
  },
});

const MyInputType = t.inputObjectType<{ id: string }>({
  name: 'MyInputType',
  extensions: {
    // arg should have the same type as input
    parse: async (input) => {
      // input.id is a Relay Global Object Identifier, it needs to be parsed into the id that is actually used in the db
      return {
        realId: fromGlobalId(input.id),
      };
    },
  },
  fields: () => [t.defaultField('id', t.ID)],
});

const Mutation = t.mutationType({
  fields: [
    t.field('doStuffOnlyAdminShouldDo', {
      type: SomeResponseType,
      args: {
        input: t.arg(t.NonNullInput(MyInputType)),
      }, 
      // type of `args.input` is now the return type of its parse extension
      // { realId: string } 
      resolve: (_, args, ctx) => {},
    }),
  ],
});

I expect it wouldnt be feasible, but a simpler version where you only validate the input and throw an exception if its bad (without mutating args) might be.

There is a lot of inspiration to be taken from nexus's plugins https://nexusjs.org/docs/api/plugins

sikanhe commented 3 years ago

I looked through Nexus' plugin, most of them should not need extensions. Just plain, reuse-able higher order functions.

Take your authentication for example, we just create a function that takes a regular resolver, and return a new resolver, and can be used on any field that requires auth

const adminOnly = (resolver) => (parent, args, ctx) => {
   if (Permissions.isAdmin(ctx.currentUser)) return resolver(ctx, parent, args);
   return null // or return error
}

fields: [
   t.field('allUsers', {
      type: ListOfUsers,
      resolver: adminOnly(() => { return Users.getAll() })
   })
]

The same pattern can be applied to pretty everything on that plugin list.

For the relay ID example. You create a new GraphQL RelayId Scalar type, which is used for parsing and serializing a leaf node. Roughly

const globalId = t.scalarType('globalId', {
   serialize: toGlobalId,
   parse: fromGlobalId
})

fields: [
  t.defaultField("id", globalId)
]
Ericnr commented 3 years ago

yea, thats a totally fair decision. These are quality of life features only that could make the complexity explode