DEV Community

Cover image for Building a Catalog GraphQL API with InversifyJS - Part 3: Implementing Resolvers & Server Setup
notaphplover
notaphplover

Posted on

Building a Catalog GraphQL API with InversifyJS - Part 3: Implementing Resolvers & Server Setup

In Part 2, we set up our database with Prisma and implemented our repositories. Now, in Part 3, we will implement the GraphQL resolvers and wire everything together using InversifyJS to create a running Apollo Server.

Step 1: Defining the Context

First, let's define the Context interface that will be passed to our resolvers. This usually contains the request object and any other global state.

Create src/graphql/models/Context.ts:

import type express from 'express';

export interface Context {
  readonly request: express.Request;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Updating Repositories

Before we implement the resolvers, we need to add some methods to our repositories to support the queries and mutations we'll be implementing.

Category Repository

Update src/category/repositories/CategoryRepository.ts to add createOne, findPaginatedAll, and findOneById:

  import { inject, injectable } from "inversify";
- import { PrismaClient } from "../../../generated/index.js";
+ import { Category, PrismaClient } from "../../../generated/index.js";

  @injectable()
  export class CategoryRepository {
    readonly #client: PrismaClient;

    constructor(@inject(PrismaClient) client: PrismaClient) {
      this.#client = client;
    }

    public async count(): Promise<number> {
      return this.#client.category.count();
    }
+
+   public async createOne(name: string, slug: string): Promise<Category> {
+     return this.#client.category.create({
+       data: {
+         name,
+         slug,
+       },
+     });
+   }
+
+   public async findPaginatedAll(
+     first: number,
+     after?: string,
+   ): Promise<Category[]> {
+     return this.#client.category.findMany({
+       cursor: after ? { id: after } : undefined,
+       skip: after ? 1 : 0,
+       take: first,
+     });
+   }
+
+   public async findOneById(id: string): Promise<Category | undefined> {
+     const category = await this.#client.category.findUnique({
+       where: { id },
+     });
+     return category ?? undefined;
+   }
  }
Enter fullscreen mode Exit fullscreen mode

Product Repository

Update src/product/repositories/ProductRepository.ts to add createOne, findPaginatedAllByCategoryId, countByCategoryId, and findOneById:

  import { inject, injectable } from "inversify";
- import { PrismaClient } from "../../../generated/index.js";
+ import { PrismaClient, Product } from "../../../generated/index.js";

  @injectable()
  export class ProductRepository {
    readonly #client: PrismaClient;

    constructor(@inject(PrismaClient) client: PrismaClient) {
      this.#client = client;
    }

    public async count(): Promise<number> {
      return this.#client.product.count();
    }
+
+   public async createOne(
+     categoryId: string,
+     title: string,
+     description: string,
+     currency: string,
+     price: number,
+   ): Promise<Product> {
+     return this.#client.product.create({
+       data: {
+         categoryId,
+         currency,
+         description,
+         price,
+         title,
+       },
+     });
+   }
+
+   public async findPaginatedAllByCategoryId(
+     categoryId: string,
+     first: number,
+     after?: string,
+   ): Promise<Product[]> {
+     return this.#client.product.findMany({
+       cursor: after ? { id: after } : undefined,
+       skip: after ? 1 : 0,
+       take: first,
+       where: { categoryId },
+     });
+   }
+
+   public async countByCategoryId(categoryId: string): Promise<number> {
+     return this.#client.product.count({
+       where: { categoryId },
+     });
+   }
+
+   public async findOneById(id: string): Promise<Product | undefined> {
+     const product = await this.#client.product.findUnique({
+       where: { id },
+     });
+     return product ?? undefined;
+   }
  }
Enter fullscreen mode Exit fullscreen mode

Step 3: Implementing Resolvers

We will implement our resolvers as injectable classes. This allows us to inject our repositories directly into them.

Category Resolvers

We need to implement the Category field resolvers, specifically for the products field, to handle the relationship between Category and Product. We'll implement full pagination logic here.

Create src/category/resolvers/CategoryResolvers.ts:

import { inject, injectable } from 'inversify';
import { type Product as ProductDb } from '../../../generated/index.js';
import { Context } from '../../graphql/models/Context.js';
import type * as graphqlModels from '../../graphql/models/types.js';
import { ProductRepository } from '../../product/repositories/ProductRepository.js';

const MAX_ELEMENTS_PER_PAGE: number = 20;

@injectable()
export class CategoryResolvers implements graphqlModels.CategoryResolvers<Context> {
  readonly #productRepository: ProductRepository;

  constructor(
    @inject(ProductRepository)
    productRepository: ProductRepository,
  ) {
    this.#productRepository = productRepository;
  }

  public id(parent: graphqlModels.Category): string {
    return parent.id;
  }

  public name(parent: graphqlModels.Category): string {
    return parent.name;
  }

  public async products(
    parent: graphqlModels.Category,
    args: Partial<graphqlModels.CategoryProductsArgs>,
  ): Promise<graphqlModels.ProductConnection> {
    const firstValue: number = Math.max(
      Math.min(args.first ?? MAX_ELEMENTS_PER_PAGE, MAX_ELEMENTS_PER_PAGE),
      0,
    );

    const [productsDb, totalCount]: [ProductDb[], number] = await Promise.all([
      this.#productRepository.findPaginatedAllByCategoryId(
        parent.id,
        firstValue,
        args.after ?? undefined,
      ),
      this.#productRepository.countByCategoryId(parent.id),
    ]);

    const endCursor: string | null =
      productsDb.length > 0
        ? (productsDb[productsDb.length - 1]?.id ?? null)
        : null;

    const startCursor: string | null =
      productsDb.length > 0 ? (productsDb[0]?.id ?? null) : null;

    return {
      edges: productsDb.map((product: ProductDb) => ({
        cursor: product.id,
        node: {
          currency: product.currency,
          description: product.description,
          id: product.id,
          price: product.price,
          title: product.title,
        },
      })),
      pageInfo: {
        endCursor,
        hasNextPage: productsDb.length === firstValue,
        hasPreviousPage: args.after != null,
        startCursor,
      },
      totalCount,
    };
  }

  public slug(parent: graphqlModels.Category): string {
    return parent.slug;
  }
}
Enter fullscreen mode Exit fullscreen mode

Query Resolvers

Now let's implement the root Query resolvers, including pagination for categories and fetching individual items.

Create src/app/resolvers/QueryResolvers.ts:

import { inject, injectable } from 'inversify';
import {
  type Category as CategoryDb,
  type Product as ProductDb,
} from '../../../generated/index.js';
import { CategoryRepository } from '../../category/repositories/CategoryRepository.js';
import { type Context } from '../../graphql/models/Context.js';
import type * as graphqlModels from '../../graphql/models/types.js';
import { ProductRepository } from '../../product/repositories/ProductRepository.js';

const MAX_ELEMENTS_PER_PAGE: number = 20;

@injectable()
export class QueryResolvers implements graphqlModels.QueryResolvers<Context> {
  readonly #categoryRepository: CategoryRepository;
  readonly #productRepository: ProductRepository;

  constructor(
    @inject(CategoryRepository)
    categoryRepository: CategoryRepository,
    @inject(ProductRepository)
    productRepository: ProductRepository,
  ) {
    this.#categoryRepository = categoryRepository;
    this.#productRepository = productRepository;
  }

  public async categories(
    _parent: unknown,
    args: graphqlModels.QueryCategoriesArgs,
  ): Promise<graphqlModels.CategoryConnection> {
    const firstValue: number = Math.max(
      Math.min(args.first ?? MAX_ELEMENTS_PER_PAGE, MAX_ELEMENTS_PER_PAGE),
      0,
    );

    const [categoriesDb, totalCount]: [CategoryDb[], number] =
      await Promise.all([
        this.#categoryRepository.findPaginatedAll(
          firstValue,
          args.after ?? undefined,
        ),
        this.#categoryRepository.count(),
      ]);

    const endCursor: string | null =
      categoriesDb.length > 0
        ? (categoriesDb[categoriesDb.length - 1]?.id ?? null)
        : null;

    const startCursor: string | null =
      categoriesDb.length > 0 ? (categoriesDb[0]?.id ?? null) : null;

    return {
      edges: categoriesDb.map((category: CategoryDb) => ({
        cursor: category.id,
        node: {
          id: category.id,
          name: category.name,
          slug: category.slug,
        },
      })),
      pageInfo: {
        endCursor,
        hasNextPage: categoriesDb.length === firstValue,
        hasPreviousPage: args.after != null,
        startCursor,
      },
      totalCount,
    };
  }

  public async category(
    _parent: unknown,
    args: graphqlModels.QueryCategoryArgs,
  ): Promise<Partial<graphqlModels.Category> | null> {
    const categoryDb: CategoryDb | undefined =
      await this.#categoryRepository.findOneById(args.id);

    if (categoryDb === undefined) {
      return null;
    }

    return {
      id: categoryDb.id,
      name: categoryDb.name,
      slug: categoryDb.slug,
    };
  }

  public async product(
    _parent: unknown,
    args: graphqlModels.QueryProductArgs,
  ): Promise<graphqlModels.Product | null> {
    const productDb: ProductDb | undefined =
      await this.#productRepository.findOneById(args.id);

    if (productDb === undefined) {
      return null;
    }

    return {
      currency: productDb.currency,
      description: productDb.description,
      id: productDb.id,
      price: productDb.price,
      title: productDb.title,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Mutation Resolvers

Create src/app/resolvers/MutationResolvers.ts:

import { inject, injectable } from 'inversify';
import {
  type Category as CategoryDb,
  type Product as ProductDb,
} from '../../../generated/index.js';
import { CategoryRepository } from '../../category/repositories/CategoryRepository.js';
import { type Context } from '../../graphql/models/Context.js';
import type * as graphqlModels from '../../graphql/models/types.js';
import { ProductRepository } from '../../product/repositories/ProductRepository.js';

@injectable()
export class MutationResolvers implements graphqlModels.MutationResolvers<Context> {
  readonly #categoryRepository: CategoryRepository;
  readonly #productRepository: ProductRepository;

  constructor(
    @inject(CategoryRepository)
    categoryRepository: CategoryRepository,
    @inject(ProductRepository)
    productRepository: ProductRepository,
  ) {
    this.#categoryRepository = categoryRepository;
    this.#productRepository = productRepository;
  }

  public async createCategory(
    _parent: unknown,
    args: graphqlModels.MutationCreateCategoryArgs,
  ): Promise<graphqlModels.Category> {
    const category: CategoryDb = await this.#categoryRepository.createOne(
      args.input.name,
      args.input.slug,
    );

    return {
      id: category.id,
      name: category.name,
      products: {
        edges: [],
        pageInfo: {
          endCursor: null,
          hasNextPage: false,
          hasPreviousPage: false,
          startCursor: null,
        },
        totalCount: 0,
      },
      slug: category.slug,
    };
  }

  public async createProduct(
    _parent: unknown,
    args: graphqlModels.MutationCreateProductArgs,
  ): Promise<graphqlModels.Product> {
    const product: ProductDb = await this.#productRepository.createOne(
      args.input.categoryId,
      args.input.title,
      args.input.description,
      args.input.currency,
      args.input.price,
    );

    return {
      currency: product.currency,
      description: product.description,
      id: product.id,
      price: product.price,
      title: product.title,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

App Resolvers

Finally, we aggregate all resolvers into a single AppResolvers class.

Create src/app/resolvers/AppResolvers.ts:

import { inject, injectable } from 'inversify';
import { CategoryResolvers } from '../../category/resolvers/CategoryResolvers.js';
import { type Context } from '../../graphql/models/Context.js';
import type * as graphqlModels from '../../graphql/models/types.js';
import { MutationResolvers } from './MutationResolvers.js';
import { QueryResolvers } from './QueryResolvers.js';

@injectable()
export class AppResolvers implements Partial<graphqlModels.Resolvers<Context>> {
  public readonly Category: graphqlModels.CategoryResolvers<Context>;
  public readonly Mutation: graphqlModels.MutationResolvers<Context>;
  public readonly Query: graphqlModels.QueryResolvers<Context>;

  constructor(
    @inject(CategoryResolvers)
    categoryResolvers: CategoryResolvers,
    @inject(MutationResolvers)
    mutationResolvers: MutationResolvers,
    @inject(QueryResolvers)
    queryResolvers: QueryResolvers,
  ) {
    this.Category = categoryResolvers;
    this.Mutation = mutationResolvers;
    this.Query = queryResolvers;
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Wiring the Container

We need to bind our resolvers in the Inversify container.

Update Category Container Module

We need to update the CategoryContainerModule to bind the CategoryResolvers.

Update src/category/modules/CategoryContainerModule.ts:

  import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';
  import { CategoryRepository } from '../repositories/CategoryRepository.js';
+ import { CategoryResolvers } from '../resolvers/CategoryResolvers.js';

  export class CategoryContainerModule extends ContainerModule {
    constructor() {
      super((options: ContainerModuleLoadOptions): void => {
        options.bind(CategoryRepository).toSelf().inSingletonScope();
+       options.bind(CategoryResolvers).toSelf().inSingletonScope();
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

Create App Container Module

We also need a module for the application-level resolvers.

Create src/app/modules/AppContainerModule.ts:

import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';
import { AppResolvers } from '../resolvers/AppResolvers.js';
import { MutationResolvers } from '../resolvers/MutationResolvers.js';
import { QueryResolvers } from '../resolvers/QueryResolvers.js';

export class AppContainerModule extends ContainerModule {
  constructor() {
    super((options: ContainerModuleLoadOptions): void => {
      options.bind(AppResolvers).toSelf().inSingletonScope();
      options.bind(MutationResolvers).toSelf().inSingletonScope();
      options.bind(QueryResolvers).toSelf().inSingletonScope();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Server Bootstrap

Now we will create the entry point of our application, where we set up the Inversify container, load all modules, and start the Apollo Server.

Create src/app/scripts/bootstrap.ts:

import type http from 'node:http';
import { type ExpressContextFunctionArgument } from '@as-integrations/express5';
import {
  type InversifyApolloProvider,
  inversifyApolloProviderServiceIdentifier,
} from '@inversifyjs/apollo-core';
import { ApolloExpressServerContainerModule } from '@inversifyjs/apollo-express';
import { readSchemas } from '@inversifyjs/graphql-codegen';
import { InversifyExpressHttpAdapter } from '@inversifyjs/http-express';
import { Container } from 'inversify';

import { CategoryContainerModule } from '../../category/modules/CategoryContainerModule.js';
import { PrismaModule } from '../../foundation/db/modules/PrismaModule.js';
import { type Context } from '../../graphql/models/Context.js';
import { ProductContainerModule } from '../../product/modules/ProductContainerModule.js';
import { AppContainerModule } from '../modules/AppContainerModule.js';
import { AppResolvers } from '../resolvers/AppResolvers.js';

const PORT: number = 3000;

const container: Container = new Container();

await container.load(
  new AppContainerModule(),
  ApolloExpressServerContainerModule.graphServerFromOptions<Context>(
    {
      controllerOptions: {
        path: '',
      },
      getContext: async (
        arg: ExpressContextFunctionArgument,
      ): Promise<Context> => ({
        request: arg.req,
      }),
    },
    {
      resolverServiceIdentifier: AppResolvers,
      typeDefs: await readSchemas({
        glob: {
          patterns: ['./graphql/schemas/**/*.graphql'],
        },
      }),
    },
  ),
  new CategoryContainerModule(),
  new PrismaModule(),
  new ProductContainerModule(),
);

const adapter: InversifyExpressHttpAdapter = new InversifyExpressHttpAdapter(
  container,
);

await adapter.build();

const inversifyApolloProvider: InversifyApolloProvider<http.Server> =
  await container.getAsync(inversifyApolloProviderServiceIdentifier);

await new Promise<void>((resolve: () => void) => {
  inversifyApolloProvider.server.listen(PORT, () => {
    resolve();
  });
});

console.log(`Server is running on http://localhost:${PORT.toString()}`);
Enter fullscreen mode Exit fullscreen mode

And finally, update src/index.ts to import the bootstrap script:

import './app/scripts/bootstrap.js';
Enter fullscreen mode Exit fullscreen mode

Conclusion

We have successfully implemented our GraphQL resolvers and set up the Apollo Server using InversifyJS. We can now run our application and interact with our API!

In the next part, we will implement graphql subscriptions on top of web sockets and a Redis Pub/Sub pattern.

Top comments (0)