Skip to main content

Extend the GraphQL API

Extension to the GraphQL API consists of two parts:

  1. Schema extensions. These define new types, fields, queries and mutations.
  2. Resolvers. These provide the logic that backs up the schema extensions.

The Shop API and Admin APIs can be extended independently:

src/plugins/top-products/top-products.plugin.ts
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import gql from 'graphql-tag';
import { TopSellersResolver } from './api/top-products.resolver';

const schemaExtension = gql`
extend type Query {
topProducts: [Product!]!
}
`

@VendurePlugin({
imports: [PluginCommonModule],
// We pass our schema extension and any related resolvers
// to our plugin metadata
shopApiExtensions: {
schema: schemaExtension,
resolvers: [TopProductsResolver],
},
// Likewise, if you want to extend the Admin API,
// you would use `adminApiExtensions` in exactly the
// same way.
// adminApiExtensions: {
// schema: someSchemaExtension
// resolvers: [SomeResolver],
// },
})
export class TopProductsPlugin {
}

There are a number of ways the GraphQL APIs can be modified by a plugin.

Adding a new Query

Let's take a simple example where we want to be able to display a banner in our storefront.

First let's define a new query in the schema:

src/plugins/banner/api/api-extensions.ts
import gql from 'graphql-tag';

export const shopApiExtensions = gql`
extend type Query {
activeBanner(locationId: String!): String
}
`;

This defines a new query called activeBanner which takes a locationId string argument and returns a string.

tip

! = non-nullable

In GraphQL, the ! in locationId: String! indicates that the argument is required, and the lack of a ! on the return type indicates that the return value can be null.

We can now define the resolver for this query:

src/plugins/banner/api/banner-shop.resolver.ts
import { Args, Query, Resolver } from '@nestjs/graphql';
import { Ctx, RequestContext } from '@vendure/core';
import { BannerService } from '../services/banner.service.ts';

@Resolver()
class BannerShopResolver {
constructor(private bannerService: BannerService) {}

@Query()
activeBanner(@Ctx() ctx: RequestContext, @Args() args: { locationId: string; }) {
return this.bannerService.getBanner(ctx, args.locationId);
}
}

The BannerService would implement the actual logic for fetching the banner text from the database.

Finally, we need to add the resolver to the plugin metadata:

src/plugins/banner/banner.plugin.ts
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { BannerService } from './services/banner.service';
import { BannerShopResolver } from './api/banner-shop.resolver';
import { shopApiExtensions } from './api/api-extensions';

@VendurePlugin({
imports: [PluginCommonModule],
shopApiExtensions: {
schema: shopApiExtensions,
resolvers: [BannerShopResolver],
},
providers: [BannerService],
})
export class BannerPlugin {}

Adding a new Mutation

Let's continue the BannerPlugin example and now add a mutation which allows the administrator to set the banner text.

First we define the mutation in the schema:

src/plugins/banner/api/api-extensions.ts
import gql from 'graphql-tag';

export const adminApiExtensions = gql`
extend type Mutation {
setBannerText(locationId: String!, text: String!): String!
}
`;

Here we are defining a new mutation called setBannerText which takes two arguments, locationId and text, both of which are required strings. The return type is a non-nullable string.

Now let's define a resolver to handle that mutation:

src/plugins/banner/api/banner-admin.resolver.ts
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { Allow, Ctx, RequestContext, Permission, Transaction } from '@vendure/core';
import { BannerService } from '../services/banner.service.ts';

@Resolver()
class BannerAdminResolver {
constructor(private bannerService: BannerService) {}

@Allow(Permission.UpdateSettings)
@Transaction()
@Mutation()
setBannerText(@Ctx() ctx: RequestContext, @Args() args: { locationId: string; text: string; }) {
return this.bannerService.setBannerText(ctx, args.locationId, args.text);
}
}

Note that we have used the @Allow() decorator to ensure that only users with the UpdateSettings permission can call this mutation. We have also wrapped the resolver in a transaction using @Transaction(), which is a good idea for any mutation which modifies the database.

info

For more information on the available decorators, see the API Layer "decorators" guide.

Finally, we add the resolver to the plugin metadata:

src/plugins/banner/banner.plugin.ts
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { BannerService } from './services/banner.service';
import { BannerShopResolver } from './api/banner-shop.resolver';
import { BannerAdminResolver } from './api/banner-admin.resolver';
import { shopApiExtensions, adminApiExtensions } from './api/api-extensions';

@VendurePlugin({
imports: [PluginCommonModule],
shopApiExtensions: {
schema: shopApiExtensions,
resolvers: [BannerShopResolver],
},
adminApiExtensions: {
schema: adminApiExtensions,
resolvers: [BannerAdminResolver],
},
providers: [BannerService],
})
export class BannerPlugin {}

Defining a new type

If you have defined a new database entity, it is likely that you'll want to expose this entity in your GraphQL API. To do so, you'll need to define a corresponding GraphQL type.

Using the ProductReview entity from the Define a database entity guide, let's see how we can expose it as a new type in the API.

As a reminder, here is the ProductReview entity:

src/plugins/reviews/product-review.entity.ts
import { DeepPartial } from '@vendure/common/lib/shared-types';
import { VendureEntity, Product, EntityId, ID } from '@vendure/core';
import { Column, Entity, ManyToOne } from 'typeorm';

@Entity()
class ProductReview extends VendureEntity {
constructor(input?: DeepPartial<ProductReview>) {
super(input);
}

@ManyToOne(type => Product)
product: Product;

@EntityId()
productId: ID;

@Column()
text: string;

@Column()
rating: number;
}

Let's define a new GraphQL type which corresponds to this entity:

src/plugins/reviews/api/api-extensions.ts
import gql from 'graphql-tag';

export const apiExtensions = gql`
type ProductReview implements Node {
id: ID!
createdAt: DateTime!
updatedAt: DateTime!
product: Product!
productId: ID!
text: String!
rating: Float!
}
`;
info

Assuming the entity is a standard VendureEntity, it is good practice to always include the id, createdAt and updatedAt fields in the GraphQL type.

Additionally, we implement Node which is a built-in GraphQL interface.

Now we can add this type to both the Admin and Shop APIs:

src/plugins/reviews/reviews.plugin.ts
import gql from 'graphql-tag';
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { ReviewsResolver } from './api/reviews.resolver';
import { apiExtensions } from './api/api-extensions';
import { ProductReview } from './entities/product-review.entity';

@VendurePlugin({
imports: [PluginCommonModule],
shopApiExtensions: {
schema: apiExtensions,
},
entities: [ProductReview],
})
export class ReviewsPlugin {}

Add fields to existing types

Let's say you want to add a new field to the ProductVariant type to allow the storefront to display some indication of how long a particular product variant would take to deliver, based on data from some external service.

First we extend the ProductVariant GraphQL type:

src/plugins/delivery-time/api/api-extensions.ts
import gql from 'graphql-tag';

export const shopApiExtensions = gql`
type DeliveryEstimate {
from: Int!
to: Int!
}

extend type ProductVariant {
delivery: DeliveryEstimate!
}
}`;

This schema extension says that the delivery field will be added to the ProductVariant type, and that it will be of type DeliveryEstimate!, i.e. a non-nullable instance of the DeliveryEstimate type.

Next we need to define an "entity resolver" for this field. Unlike the resolvers we have seen above, this resolver will be handling fields on the ProductVariant type only. This is done by scoping the resolver class that type by passing the type name to the @Resolver() decorator:

src/plugins/delivery-time/product-variant-entity.resolver.ts
import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Ctx, RequestContext, ProductVariant } from '@vendure/core';
import { DeliveryEstimateService } from '../services/delivery-estimate.service';

@Resolver('ProductVariant')
export class ProductVariantEntityResolver {
constructor(private deliveryEstimateService: DeliveryEstimateService) { }

@ResolveField()
delivery(@Ctx() ctx: RequestContext, @Parent() variant: ProductVariant) {
return this.deliveryEstimateService.getEstimate(ctx, variant.id);
}
}

Finally we need to pass these schema extensions and the resolver to our plugin metadata:

src/plugins/delivery-time/delivery-time.plugin.ts
import gql from 'graphql-tag';
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { ProductVariantEntityResolver } from './api/product-variant-entity.resolver';
import { shopApiExtensions } from './api/api-extensions';

@VendurePlugin({
imports: [PluginCommonModule],
shopApiExtensions: {
schema: shopApiExtensions,
resolvers: [ProductVariantEntityResolver]
}
})
export class DeliveryTimePlugin {}

Override built-in resolvers

It is also possible to override an existing built-in resolver function with one of your own. To do so, you need to define a resolver with the same name as the query or mutation you wish to override. When that query or mutation is then executed, your code, rather than the default Vendure resolver, will handle it.

import { Args, Query, Mutation, Resolver } from '@nestjs/graphql';
import { Ctx, RequestContext } from '@vendure/core'

@Resolver()
class OverrideExampleResolver {

@Query()
products(@Ctx() ctx: RequestContext, @Args() args: any) {
// when the `products` query is executed, this resolver function will
// now handle it.
}

@Transaction()
@Mutation()
addItemToOrder(@Ctx() ctx: RequestContext, @Args() args: any) {
// when the `addItemToOrder` mutation is executed, this resolver function will
// now handle it.
}

}

The same can be done for resolving fields:

import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Ctx, RequestContext, Product } from '@vendure/core';

@Resolver('Product')
export class FieldOverrideExampleResolver {

@ResolveField()
description(@Ctx() ctx: RequestContext, @Parent() product: Product) {
return this.wrapInFormatting(ctx, product.id);
}

private wrapInFormatting(ctx: RequestContext, id: ID): string {
// implementation omitted, but wraps the description
// text in some special formatting required by the storefront
}
}

Resolving union results

When dealing with operations that return a GraphQL union type, there is an extra step needed.

Union types are commonly returned from mutations in the Vendure APIs. For more detail on this see the section on ErrorResults. For example:

type MyCustomErrorResult implements ErrorResult {
errorCode: ErrorCode!
message: String!
}

union MyCustomMutationResult = Order | MyCustomErrorResult

extend type Mutation {
myCustomMutation(orderId: ID!): MyCustomMutationResult!
}

In this example, the resolver which handles the myCustomMutation operation will be returning either an Order object or a MyCustomErrorResult object. The problem here is that the GraphQL server has no way of knowing which one it is at run-time. Luckily Apollo Server (on which Vendure is built) has a means to solve this:

To fully resolve a union, Apollo Server needs to specify which of the union's types is being returned. To achieve this, you define a __resolveType function for the union in your resolver map.

The __resolveType function is responsible for determining an object's corresponding GraphQL type and returning the name of that type as a string.

-- Source: Apollo Server docs

In order to implement a __resolveType function as part of your plugin, you need to create a dedicated Resolver class with a single field resolver method which will look like this:

import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Ctx, RequestContext, ProductVariant } from '@vendure/core';

@Resolver('MyCustomMutationResult')
export class MyCustomMutationResultResolver {

@ResolveField()
__resolveType(value: any): string {
// If it has an "id" property we can assume it is an Order.
return value.hasOwnProperty('id') ? 'Order' : 'MyCustomErrorResult';
}
}

This resolver is then passed in to your plugin metadata like any other resolver:

@VendurePlugin({
imports: [PluginCommonModule],
shopApiExtensions: {
schema: apiExtensions,
resolvers: [/* ... */, MyCustomMutationResultResolver]
}
})
export class MyPlugin {}

Defining custom scalars

By default, Vendure bundles DateTime and a JSON custom scalars (from the graphql-scalars library). From v1.7.0, you can also define your own custom scalars for use in your schema extensions:

import { GraphQLScalarType} from 'graphql';
import { GraphQLEmailAddress } from 'graphql-scalars';

// Scalars can be custom-built as like this one,
// or imported from a pre-made scalar library like
// the GraphQLEmailAddress example.
const FooScalar = new GraphQLScalarType({
name: 'Foo',
description: 'A test scalar',
serialize(value) {
// ...
},
parseValue(value) {
// ...
},
});

@VendurePlugin({
imports: [PluginCommonModule],
shopApiExtensions: {
schema: gql`
scalar Foo
scalar EmailAddress
`,
scalars: {
// The key must match the scalar name
// given in the schema
Foo: FooScalar,
EmailAddress: GraphQLEmailAddress,
},
},
})
export class CustomScalarsPlugin {}