javagraphql-javagraphql-spqr

How do I add a custom directive to a query resolved through a singleton


I have managed to add custom directives to the GraphQL schema but I am struggling to work out how to add a custom directive to a field definition. Any hints on the correct implementation would be very helpful. I am using GraphQL SPQR 0.9.6 to generate my schema


Solution

  • ORIGINAL ANSWER: (now outdated, see the 2 updates below)

    It's currently not possible to do this. GraphQL SPQR v0.9.9 will be the first to support custom directives.

    Still, in 0.9.8 there's a possible work-around, depending on what you're trying to achieve. SPQR's own meta-data about a field or a type is kept inside custom directives. Knowing that, you can get a hold of the Java method/field underlying the GraphQL field definition. If what you want is e.g. an instrumentation that does something based on a directive, you could instead obtain any annotations on the underlying element, having the full power of Java at your disposal.

    The way to get the method would something like:

    Operation operation = Directives.getMappedOperation(env.getField()).get();
    Resolver resolver = operation.getApplicableResolver(env.getArguments().keySet());
    Member underlyingElement = resolver.getExecutable().getDelegate();
    

    UPDATE: I posted a huge answer on this GitHub issue. Pasting it here as well.

    You can register an additional directive as such:

    generator.withSchemaProcessors(
        (schemaBuilder, buildContext) -> schemaBuilder.additionalDirective(...));
    

    But (according to my current understanding), this only makes sense for query directives (something the client sends as a part of the query, like @skip or @deffered).

    Directives like @dateFormat simply make no sense in SPQR: they're there to help you when parsing SDL and mapping it to your code. In SPQR, there's no SDL and you start from your code. E.g. @dateFormat is used to tell you that you need to provide date formatting to a specific field when mapping it to Java. In SPQR you start from the Java part and the GraphQL field is generated from a Java method, so the method must already know what format it should return. Or it has an appropriate annotation already. In SPQR, Java is the source of truth. You use annotations to provide extra mapping info. Directives are basically annotation in SDL.

    Still, field or type level directives (or annotations) are very useful in instrumentations. E.g. if you want to intercept field resolution and inspect the authentication directives. In that case, I'd suggest you simply use annotations for the same purpose.

    public class BookService {
         
          @Auth(roles= {"Admin"}) //example custom annotation
          public Book addBook(Book book) { /*insert a Book into the DB */ }
    }
    

    As each GraphQLFieldDefinition is backed by a Java methods (or a field), you can get the underlying objects in your interceptor or wherever:

    GraphQLFieldDefinition field = ...;
    Operation operation = Directives.getMappedOperation(field).get();
    
    //Multiple methods can be hooked up to a single GraphQL operation. This gets the @Auth annotations from all of them
    Set<Auth> allAuthAnnotations = operation.getResolvers().stream()
                    .map(res -> res.getExecutable().getDelegate()) //get the underlying method
                    .filter(method -> method.isAnnotationPresent(Auth.class))
                    .map(method -> method.getAnnotation(Auth.class))
                    .collect(Collectors.toSet());
    

    Or, to inspect only the method that can handle the current request:

    DataFetchingEnvironment env = ...; //get it from the instrumentation params      
    Auth auth = operation.getApplicableResolver(env.getArguments().keySet()).getExecutable().getDelegate().getAnnotation(Auth.class);
    

    Then you can inspect your annotations as you wish, e.g.

    Set<String> allNeededRoles = allAuthAnnotations.stream()
                                                 .flatMap(auth -> Arrays.stream(auth.roles))
                                                 .collect(Collectors.toSet());
    
    if (!currentUser.getRoles().containsAll(allNeededRoles)) {
        throw new AccessDeniedException(); //or whatever is appropriate
    }
    

    Of course, there's no real need to actually implement authentication this way, as you're probably using a framework like Spring or Guice (maybe even Jersey has the needed security features), that already has a way to intercept all methods and implement security. So you can just use that instead. Much simpler and safer. E.g. for Spring Security, just keep using it as normal:

    public class BookService {
         
          @PreAuth(...) //standard Spring Security
          public Book addBook(Book book) { /*insert a Book into the DB */ }
    }
    

    Make sure you also read my answer on implementing security in GraphQL if that's what you're after.

    You can use instrumentations to dynamically filter the results in the same way: add an annotation on a method, access it from the instrumentation, and process the result dynamically:

    public class BookService {
         
          @Filter("title ~ 'Monkey'") //example custom annotation
          public List<Book> findBooks(...) { /*get books from the DB */ }
    }
    
    new SimpleInstrumentation() {
        
        // You can also use beginFieldFetch and then onCompleted instead of instrumentDataFetcher
        @Override
        public DataFetcher<?> instrumentDataFetcher(DataFetcher<?> dataFetcher, InstrumentationFieldFetchParameters parameters) {
            GraphQLFieldDefinition field = parameters.getEnvironment().getFieldDefinition();
            Optional<String> filterExpression = Directives.getMappedOperation(field)
                    .map(operation ->
                            operation.getApplicableResolver(parameters.getEnvironment().getArguments().keySet())
                                    .getExecutable().getDelegate()
                                    .getAnnotation(Filter.class).value()); //get the filtering expression from the annotation
            return filterExpression.isPresent() ? env -> filterResultBasedOn Expression(dataFetcher.get(parameters.getEnvironment()), filterExpression) : dataFetcher;
        }
    }
    

    For directives on types, again, just use Java annotations. You have access to the underlying types via:

    Directives.getMappedType(graphQLType).getAnnotation(...);
    

    This, again, probably only makes sense only in instrumentations. Saying that because normally the directives provide extra info to map SDL to a GraphQL type. In SPQR you map a Java type to a GraphQL type, so a directive makes no sense in that context in most cases.

    Of course, if you still need actual GraphQL directives on a type, you can always provide a custom TypeMapper that puts them there.

    For directives on a field, it is currently not possible in 0.9.8.

    0.9.9 will have full custom directive support on any element, in case you still need them.

    UPDATE 2: GraphQL SPQR 0.9.9 is out.

    Custom directives are now supported. See issue #200 for details.

    Any custom annotation meta-annotated with @GraphQLDirective will be mapped as a directive on the annotated element.

    E.g. imagine a custom annotation @Auth(requiredRole = "Admin") used to denote access restrictions:

    @GraphQLDirective //Should be mapped as a GraphQLDirective
    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.METHOD}) //Applicable to methods
    public @interface Auth {
            String requiredRole();
    }
    

    If a resolver method is then annotated with @Auth:

    @GraphQLMutation
    @Auth(requiredRole = {"Admin"})
    public Book addBook(Book newBook) { ... }
    

    The resulting GraphQL field fill look like:

    type Mutation {
      addBook(newBook: BookInput): Book @auth(requiredRole : "Admin")
    }
    

    That is to say the @Auth annotation got mapped to a directive, due to the presence of @GraphQLDirective meta-annotation.

    Client directives can be added via: GraphQLSchemaGenerator#withAdditionalDirectives(java.lang.reflect.Type...).

    SPQR 0.9.9 also comes with ResolverInterceptors which can intercept the resolver method invocation and inspect the annotations/directives. They are much more convenient to use than Instrumentations, but are not as general (have a much more limited scope). See issue #180 for details, and the related tests for usage examples.

    E.g. to make use of the @Auth annotation from above (not that @Auth does not need to be a directive for this to work):

    public class AuthInterceptor implements ResolverInterceptor {
    
        @Override
        public Object aroundInvoke(InvocationContext context, Continuation continuation) throws Exception {
            Auth auth = context.getResolver().getExecutable().getDelegate().getAnnotation(Auth.class);
            User currentUser = context.getResolutionEnvironment().dataFetchingEnvironment.getContext();
            if (auth != null && !currentUser.getRoles().containsAll(Arrays.asList(auth.rolesRequired()))) {
                throw new IllegalAccessException("Access denied"); // or return null
                }
            return continuation.proceed(context);
        }
    }
    

    If @Auth is a directive, you can also get it via the regular API, e.g.

    List<GraphQLDirective> directives = dataFetchingEnvironment.getFieldDefinition().get.getDirectives();
    DirectivesUtil.directivesByName(directives);