Search code examples
apollo-clienttypegraphql

GraphQL combining two Resolvers


I currently have two resolvers, Authors and Books, that return data from two separate API's. In most scenarios I only need to call one or the other, however, in this scenario, I need to attach the books to the author. It's not clear to me how I should do this.

Option 1 - Simple to do

I call the book API in the Author resolver and combine them here. This means I'd potentially make unnecessary calls to the Book API. It also means if the book API changes, I'd have to make updates to both the author and book resolvers instead of just updating the Book resolver.

Option 2 - Resolver

Is there a way to call the Book resolver from within the Author resolver?

Options 3 - Client

Is there a way to stitch the author and book together from within the client query?

I’m new to graphql and type-graphql so apologies if this is obvious.

Author

const author = {
  name: 'James',
  bookIds: [1, 2]
};

Book

const book = {
  id: 1,
  title: 'Book 1'
};

Desired outcome

const author = {
  name: 'James',
  books: [{
    id: 1,
    title: 'Book 1'
  },
  {
    id: 2,
    title: 'Book 2'
  }]
}

Resolvers

@Service()
@Resolver(() => Author)
export class AuthorResolver {
  constructor(private readonly authorService: authorService) { }

  @Query(() => Author)
  async author(
    @Arg('authorId', () => ID, { nullable: false }) authorId: string,
    @Ctx() { dataSources }: ResolverContext
  ): Promise<Author | undefined> {
    const { authorService } = dataSources;
    const author = await this.author.getAuthor(authorService, authorId);

    return {
      id: author.id,
      name: author.name,
      bookIds: author.bookIds
    };
  }
}

@Service()
@Resolver(() => Book)
export class BookResolver {
  constructor(private readonly bookService: bookService) { }

  @Query(() => Book)
  async book(
    @Arg('bookId', () => ID, { nullable: false }) bookId: string,
    @Ctx() { dataSources }: ResolverContext
  ): Promise<Book | undefined> {
    const { bookService } = dataSources;
    const book = await this.book.getBook(bookService, bookId);

    return {
      id: book.id,
      title: book.title
    };
  }
}

Client Side Query

query BookQuery($bookId: ID!) {
  book(bookId: $bookId) {
    id
    title
  }
}

query authorQuery($authorId: ID!) {
  book(authorId: $authorId) {
    id
    name
    books
  }
}

Solution

  • You must implement a FieldResolver, called books, in the Author resolver.

    If an API changes in the future, it will not affect the resolver since you use a service that talks to the API and acts as a middleware layer.

    The service must be well implemented (abstraction) and the returned entity must be matched/mapped correctly to a GraphQL object type. i.e. there is no need to return {id: author.id, ...} inside a resolver since it's done automatically by the service and class mappings. 

    Moreover, you inject a service instance inside the resolver, so there is no need to use @Ctx and obtain the same service instance: simply use this.[SERVICE_NAME].[METHOD].

    Keep you context as simple as possible (e.g. authenticated user id obtained by a JWT).

    The final Author resolver is much cleaner and more portable:

    @Resolver(() => Author)
    @Service()
    export class AuthorResolver {
    
      @Inject()
      private readonly authorService!: AuthorService;
    
      @Inject()
      private readonly bookService!: BookService;
    
      // 'nullable: false' is default behaviour
      // No need for '@Ctx' here
      // Returned value is inferred from service -> 'Promise<Author | undefined>'
      @Query(() => Author)
      async author(@Arg('id', () => ID) id: string) {
        return this.authorService.findOne(id);
      }
      
      @FieldResolver(() => [Book])
      async books(@Root() author: Author) {
        return this.bookService.findManyByAuthorId(author.id);
        // OR 'return this.bookService.findMany(author.bookIds);'
      }
    }
    

    If you want an example project, see this.