type-graphql icon indicating copy to clipboard operation
type-graphql copied to clipboard

Update directives docs

Open leorrodrigues opened this issue 4 years ago • 15 comments

Since graphql-utils had updated their package to version 8.0.0, the current docs don't help users to build their resolvers with directives into the new format of graphql-utils. It'll be awesome if you guys can insert some examples of how to upgrade to version 8.0.0. Since SchemaDirectiveVisitor.visitSchemaDirectives has been removed and with makeExecutableSchema don't include the @Directive decorator into SDL, I don't know how to make the @Directive appear into resolver SDL.

leorrodrigues avatar Jul 29 '21 19:07 leorrodrigues

@leorrodrigues did you ever figure this out?

jerrywithaz avatar Aug 20 '21 00:08 jerrywithaz

@jerrywithaz yeap, it took me a while to discover how to upgrade the version and keep the directive working fine. In principle, you have to change your directive code to be a pure function, that receives a schema and returns the mapSchema from @graphql-tools/utils. The mapSchema receives two options: the schema and one object with the mapperKind (the type of value the directive is, like ROOT_FIELD, FIELD, QUERY, MUTATION, and so on) as its key, while for values you have to send another function, which finally is the code of your directive. Still, to build the schema, I'm using the buildSchemaSync function and after that, I pass this schema to the directive function.

Here is a sample code that can help you, I let two directives in the example, for OBJECT_FIELD and ROOT_FIELD. To build the Schema:

const schema = buildSchemaSync({ resolvers });
//schemaDirectives is an array with all directives that you want to be applied in the schema
const schemaWithDirectives = schemaDirectives.reduce(
    (newSchema, directive) => directive(newSchema),
    schema,
);

In the ROOT_FIELD directive, you can apply directives in the QUERY and MUTATION directly:

@Query(() => [ResponseData])
@Directive('@directiveExample1(requires: [requiredArgument])')
async queryName() {
    ... extra code
}

While the OBJECT_FIELD directive, you achieve:

@ObjectType()
export default class SomeClass {
    @Field()
    @Directive('@directiveExample2(requires: [requiredArgument])')
    someField: someType;
}

The directive code structure for the first example is:

const exampleDirective1 = (schema: GraphQLSchema) =>
mapSchema(schema, {
    [MapperKind.ROOT_FIELD]: fieldConfig => {
        if (!fieldConfig.astNode) return fieldConfig;
        const { directives } = fieldConfig.astNode;
        if (!directives) return fieldConfig;
        const directive = getDirective(directives as any, 'hasRole');
        if (!directive) return fieldConfig;
        const { resolve = defaultFieldResolver } = fieldConfig;
        fieldConfig.resolve = async (...args) => {
            your directive code...
        };
        return fieldConfig;
    },
});

For the second example, you have:

const exampleDirective2 = (schema: GraphQLSchema) =>
mapSchema(schema, {
    [MapperKind.OBJECT_FIELD]: fieldConfig => {
        if (!fieldConfig.astNode) return fieldConfig;
        const { directives } = fieldConfig.astNode;
        if (!directives) return fieldConfig;
        const directive = getDirective(directives as any, 'hasRole');
        if (!directive) return fieldConfig;
        const { resolve = defaultFieldResolver } = fieldConfig;
        fieldConfig.resolve = async (...args) => {
            your directive code...
        };
        return fieldConfig;
    },
});

But if you see, we need a function called getDirective, provided by @graphql-tools, but for some reason, it always returns undefined so I've implemented my own function to get that.

import { DirectiveNode } from 'graphql';
const getDirective = (directives: DirectiveNode[], name: string) =>
    directives.find(item => item.name.value === name);

With this sample, you can customize and implement it in your needs, I hope it helps you.

leorrodrigues avatar Aug 20 '21 17:08 leorrodrigues

Ah, that is awesome man, I was on the right track but this helped me figure it out, nice work!

I ended up creating a custom function like this:

import { GraphQLSchema } from "graphql";
import { buildSchema, BuildSchemaOptions } from "type-graphql";
import { TypeSource } from "@graphql-tools/utils";
import { mergeSchemas } from "@graphql-tools/schema";

type SchemaDirectives = {
    typeDef: TypeSource;
    directive: (schema: GraphQLSchema) => GraphQLSchema;
}

/** Enables a schema to be build with schema directives and optional additional typeDefs. */
async function buildSchemaWithDirectives({
    schemaDirectives,
    typeDefs,
    ...options
}: BuildSchemaOptions & { schemaDirectives: SchemaDirectives[], typeDefs?: TypeSource[] }): Promise<GraphQLSchema> {
    const schema = await buildSchema(options);
    
    // Add type defs for schema directives to the schema with directives
    // The type defs must be added first before executing the schema mappers
    // Or else `getDirectives` from `@graphql-tools/utils` will return undefined
    const schemaMerged = mergeSchemas({
        schemas: [schema],
        typeDefs: schemaDirectives.map((schemaDirective) => schemaDirective.typeDef), 
    });

    // Executes the schema mapper for each directive and applies it to the generated schema
    const schemaWithDirectives = schemaDirectives.reduce(
        (newSchema, { directive }) => directive(newSchema),
        schemaMerged,
    );

    return schemaWithDirectives;
}

export default buildSchemaWithDirectives;

And then my getDirective function also appends the directive to the field definition description as such:

import { GraphQLFieldConfig } from "graphql";

/** Finds a directive from the astNode of a field config. Also updates the description of a field with the directive. */
const getDirective = (
  fieldConfig: GraphQLFieldConfig<any, any>,
  name: string,
  typeDef: string
) => {
  const astNode = fieldConfig.astNode;
  const directives = astNode?.directives || [];

  const directive = directives.find((item) => item.name.value === name);

  if (directive) {
    fieldConfig.description = `${fieldConfig.description || ""} \n${typeDef}`;
  }

  return directives.find((item) => item.name.value === name);
};

export default getDirective;

jerrywithaz avatar Aug 20 '21 18:08 jerrywithaz

@leorrodrigues @jerrywithaz .. Wonderful stuff guys. I believe this must be working well for you guys. While I was looking at both the solutions, correct me If I'm wrong . The only difference I can spot between the first and second solution is the introduction of typeDefs in the second one. With the above implementation, is it possible to print a SDL with directives ?

I'm quite confused what shall I put up in typeDefs ? Do I put only those fields in which directives are required in the form of string ?

Can you please give an example, how have you guys consumed this implementation in your respective use cases ?

aman-hatcroft avatar Sep 11 '21 18:09 aman-hatcroft

@leorrodrigues , @jerrywithaz , thanks for sharing the workaround. Just thinking aloud here using workaround is good idea? I think using getDirective from graphql-tools make sense to me.

I know, its not working at this moment with type-graphql. IMO, if type-graphql adds the directive definition to typedefs, getDirective should work out of the box.

e.g.

directive @upper on FIELD_DEFINITION

I tried below things as a workaround, but it does not work out

// resolvers are defined with type-graphql
let { typeDefs, resolvers } = await buildTypeDefsAndResolvers({ resolvers });

// add directive definition
typeDefs.concat(`
directive @upper on FIELD_DEFINITION
`);

// Create the base executable schema
let schema = makeExecutableSchema({
  typeDefs,
  resolvers
});

// Transform the schema by applying directive logic
// upperDirectiveTransformer is utility method using graphql-tools
// https://www.graphql-tools.com/docs/schema-directives
schema = upperDirectiveTransformer(schema, 'upper');

// use schema with apollo server
const server = new ApolloServer({schema});

But, looks like buildTypeDefsAndResolvers does not populate @Directives in astNode.

@MichalLytek , thoughts?

RishikeshDarandale avatar Sep 22 '21 17:09 RishikeshDarandale

@RishikeshDarandale It should be stated in docs that buildTypeDefsAndResolvers is using printSchema which does not support directives. There's only a warning about query complexity.

MichalLytek avatar Sep 22 '21 17:09 MichalLytek

Thanks @MichalLytek . What would be best way to include directive definition in schema? So, OOB getDirective from graphql-tools works as expected.

RishikeshDarandale avatar Sep 23 '21 02:09 RishikeshDarandale

@jerrywithaz awesome work! In case I want to use directives to validate a user has authorization to access to a query/mutation base on his role, should I implement this logic in the context param of the graphql server?

mateo2181 avatar Sep 23 '21 16:09 mateo2181

Thanks @MichalLytek . What would be best way to include directive definition in schema? So, OOB getDirective from graphql-tools works as expected.

@MichalLytek , Is there any way to add directive @upper on FIELD_DEFINITION to schema with @Directive support?

RishikeshDarandale avatar Oct 04 '21 14:10 RishikeshDarandale

@RishikeshDarandale

export const upperCaseDirective = new GraphQLDirective({
  name: "upper",
  locations: [DirectiveLocation.FIELD_DEFINITION],
});

MichalLytek avatar Oct 04 '21 14:10 MichalLytek

I stumbled across this issue when trying to get directives to work using type-graphql and an Apollo federated server. I was able to get it working using printSchemaWithDirectives

const schema = await buildSchema({
    resolvers: [PersonResolver]
});

const typeDefs = gql(printSchemaWithDirectives(schema));
const resolvers = createResolversMap(schema);

const federatedSchema = buildFederatedSchema({
    typeDefs,
    resolvers, 
});

SchemaDirectiveVisitor.visitSchemaDirectives(federatedSchema, directives);

adw424 avatar Jul 13 '22 17:07 adw424

Hey, I want to share my authDirective based on graphql-tools Schema Directives and TypeGraphQL Authorization. I hope you find this useful (please share your thoughts and suggestions or criticisms): authDirective.ts:

import {
  GraphQLSchema,
  defaultFieldResolver,
  GraphQLFieldConfig
} from 'graphql';
import { mapSchema, MapperKind, getDirective } from '@graphql-tools/utils';
import { container } from 'tsyringe'; // Change with your IOC container
import { ClassType, ResolverData } from 'type-graphql';
import { AuthenticationError, AuthorizationError } from '~/errors'; // Auth* errors

export type AuthData<TRoles> = {
  roles: TRoles[];
};

export type AuthFn<TRoles, TContext = Record<string, unknown>> = (
  resolverData: ResolverData<TContext>,
  authData: AuthData<TRoles>
) => boolean | Promise<boolean>;

export type AuthFnClass<TRoles, TContext = Record<string, unknown>> = {
  auth(
    resolverData: ResolverData<TContext>,
    authData: AuthData<TRoles>
  ): boolean | Promise<boolean>;
};

export type Auth<TRoles, TContext = Record<string, unknown>> =
  | AuthFn<TRoles, TContext>
  | ClassType<AuthFnClass<TRoles, TContext>>;

export enum AuthMode {
  ERROR = 'ERROR',
  NULL = 'NULL'
}

export type AuthDirective<TRoles, TContext = Record<string, unknown>> = {
  directiveName: string;
  rolesName: string;
  auth: Auth<TRoles, TContext>;
  authMode?: AuthMode;
};

type AuthDirectiveLogic<
  TRoles,
  TContext = Record<string, unknown>
> = AuthDirective<TRoles, TContext> & {
  typeDirectiveArgumentMaps: Record<string, unknown>;
  schema: GraphQLSchema;
  fieldConfig: GraphQLFieldConfig<unknown, TContext>;
  typeName: string;
};

function authDirectiveLogic<TRoles, TContext = Record<string, unknown>>({
  schema,
  fieldConfig,
  directiveName,
  typeDirectiveArgumentMaps,
  typeName,
  auth,
  authMode
}: AuthDirectiveLogic<TRoles, TContext>) {
  const authGraphQLDirective = (getDirective(
    schema,
    fieldConfig,
    directiveName
  )?.[0] ?? typeDirectiveArgumentMaps[typeName]) as
    | undefined
    | null
    | { roles?: TRoles[] };
  if (
    !authGraphQLDirective || // 'null' or 'undefined'
    !authGraphQLDirective.roles || // 'roles' is 'null' or 'undefined'
    !Array.isArray(authGraphQLDirective.roles) // No 'roles' array
  ) {
    return fieldConfig;
  }

  const { roles } = authGraphQLDirective;
  const { resolve = defaultFieldResolver } = fieldConfig;

  // eslint-disable-next-line no-param-reassign
  fieldConfig.resolve = async (root, args, context, info) => {
    let accessGranted: boolean;
    const resolverData: ResolverData<TContext> = {
      root,
      args,
      context,
      info
    };
    const authData: AuthData<TRoles> = { roles };

    if (auth.prototype) {
      // Auth class
      const authInstance = container.resolve(
        auth as ClassType<AuthFnClass<TRoles, TContext>>
      );
      accessGranted = await authInstance.auth(resolverData, authData);
    } else {
      // Auth function
      accessGranted = await (auth as AuthFn<TRoles, TContext>)(
        resolverData,
        authData
      );
    }

    if (!accessGranted) {
      switch (authMode) {
        case AuthMode.NULL:
          return null;
        case AuthMode.ERROR:
        default:
          throw roles.length === 0
            ? new AuthenticationError()
            : new AuthorizationError();
      }
    }

    return resolve(root, args, context, info);
  };

  return fieldConfig;
}

export function authDirective<TRoles, TContext = Record<string, unknown>>(
  args: AuthDirective<TRoles, TContext>
) {
  const typeDirectiveArgumentMaps: Record<string, unknown> = {};

  return {
    typeDef: `
    directive @${args.directiveName}(
      roles: [${args.rolesName}!]! = [],
    ) on OBJECT | FIELD_DEFINITION`,
    transformer: (schema: GraphQLSchema) => {
      return mapSchema(schema, {
        [MapperKind.TYPE]: (type) => {
          const authGraphQLDirective = getDirective(
            schema,
            type,
            args.directiveName
          )?.[0];
          if (authGraphQLDirective) {
            typeDirectiveArgumentMaps[type.name] = authGraphQLDirective;
          }
          return undefined;
        },
        [MapperKind.ROOT_FIELD]: (fieldConfig, _, typeName) =>
          authDirectiveLogic({
            ...args,
            typeDirectiveArgumentMaps,
            schema,
            fieldConfig,
            typeName
          }),
        [MapperKind.OBJECT_FIELD]: (fieldConfig, _, typeName) =>
          authDirectiveLogic({
            ...args,
            typeDirectiveArgumentMaps,
            schema,
            fieldConfig,
            typeName
          })
      });
    }
  };
}

Then you can build your auth directive (e.g. for a user) as follows: authUserDirective.ts

import { authDirective, AuthFn } from './authDirective';

// Remember to register enum in schema
export enum UserRoles {
  ADMIN = 'ADMIN',
  USER = 'USER'
}

export type Context = {
  user?: { id: string; roles: UserRoles[] };
};

const authFn: AuthFn<UserRoles, Context> = (
  { context: { user } },
  { roles }
) => {
  if (!user) {
    // No user
    return false;
  }

  if (roles.length === 0) {
    // '@auth' without roles
    return true;
  }

  // Check user roles overlap
  return user.roles.some((role) => roles.includes(role));
};

export const authUserDirective = authDirective({
  directiveName: 'auth',
  rolesName: 'UserRoles',
  auth: authFn
});

Use it in ObjectType, Field, Query or Mutation:

@Query(() => [ResponseData])
@Directive(`@auth(roles: [ADMIN])`)
async queryName() { // ... }

@ObjectType()
@Directive(`@auth`)
export default class SomeClass {
    @Field()
    @Directive(`@auth(roles: [ADMIN])`)
    privateToAdmin: someType;

    @Field()
    publicForAllAuth: string;
}

And build the schema (thanks @jerrywithaz) as follows: schema.ts:

import { GraphQLSchema } from 'graphql';
import { buildSchemaSync } from 'type-graphql';
import { mergeSchemas } from '@graphql-tools/schema';
import { container } from 'tsyringe'; // Change with your IOC container
import { authUserDirective } from './directives';
import { UserResolver } from './resolvers';

// Directives
const directives = [authUserDirective] as const;

// Resolvers
const resolvers = [UserResolver] as const;

const schemaSimple = buildSchemaSync({
  resolvers,
  container: { get: (cls) => container.resolve(cls) }
});

const schemaMerged = mergeSchemas({
  schemas: [schemaSimple],
  typeDefs: directives.map((directive) => directive.typeDef)
});

export const schema: GraphQLSchema = directives.reduce(
  (newSchema, { transformer }) => transformer(newSchema),
  schemaMerged
);

carlocorradini avatar Oct 06 '22 17:10 carlocorradini

Hey everyone, if you are struggling with @auth directive I've created the graphql-auth-directive library.  It's fully configurable and compatible with vanilla GraphQL and TypeGraphQL. See this. I hope it will be useful :wave:

carlocorradini avatar Oct 12 '22 13:10 carlocorradini

Any good examples for using rest directives as a proxy?

Something like this example.

rafaell-lycan avatar Nov 01 '22 12:11 rafaell-lycan

I think implementing directives is out of TypeGraphQL scope for now.

The updated codebase now uses this approach: https://github.com/MichalLytek/type-graphql/blob/master/tests/helpers/directives/TestDirective.ts

I'm not sure if the issue is strictly about the snippet in the docs: https://typegraphql.com/docs/directives.html#providing-the-implementation

MichalLytek avatar Feb 08 '23 15:02 MichalLytek