Experimenting with AccountsJs and NestJS GraphQL

Creating a new project

nest new -p yarn backend

Installing dependencies

yarn add @accounts/graphql-api \
  @accounts/typeorm \
  @accounts/server \
  @accounts/password \
  @graphql-modules/core \
  @nestjs/config \
  @nestjs/graphql \
  graphql-tools \
  graphql \
  apollo-server-express \
  @nestjs/typeorm \
  typeorm \
  pg

Scaffold

touch .env docker-compose.yml \
  && for schematic in module resolver; do nest g $schematic users; done; \
  nest g decorator current-user \
  && touch ./src/users/user.entity.ts \
  && rm ./src/app.{service,controller}*

.env :

TOKEN_SECRET=supersecret
APP_NAME=Auth
TYPEORM_CONNECTION=postgres
TYPEORM_HOST=127.0.0.1
TYPEORM_USERNAME=auth
TYPEORM_PASSWORD=secret
TYPEORM_DATABASE=auth
TYPEORM_PORT=5432
TYPEORM_LOGGING=true
TYPEORM_ENTITIES=src/**/*.entity.ts,node_modules/@accounts/typeorm/lib/entity/*.js
TYPEORM_MIGRATIONS=database/migrations/*.ts
TYPEORM_MIGRATIONS_DIR=database/migrations

docker-compose.yml :

version: '3.8'

volumes:
  auth-db:

services:
  database:
    image: postgres:12
    ports:
      - 5432:5432
    volumes:
      - auth-db:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${TYPEORM_DATABASE}
      POSTGRES_USER: ${TYPEORM_USERNAME}
      POSTGRES_PASSWORD: ${TYPEORM_PASSWORD}

package.json :

// ...
   "scripts": {
      // ...
      "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js"
   },
// ...

tsconfig.build.json :

{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "database"]
}

Initial Setup

We’ll start by running the database, generating our first migration and execute it.

docker-compose up -d \
  && yarn typeorm migration:generate -n Init \
  && yarn typeorm migration:run

current-user.decorator.ts

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AccountsModuleContext } from '@accounts/graphql-api';
import { GqlExecutionContext } from '@nestjs/graphql';

export const CurrentUser = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) =>
    GqlExecutionContext.create(ctx).getContext<AccountsModuleContext>().user,
);

user.entity.ts :

Since we’re using the code first approach, we’re required to define at least one Field.

import { Entity } from 'typeorm';
import { Field, ObjectType } from '@nestjs/graphql';
import { User as AccountsUser } from '@accounts/typeorm';

@Entity()
@ObjectType()
export class User extends AccountsUser {
  @Field()
  public get avatar(): string {
    return 'https://via.placeholder.com/150';
  }
}

users.resolver.ts :

The code first approach also requires us the define at least one Query.

Furthermore, in order for the User type to be generated and be able to extend it, we’ll need a resolver for that type.

We’ll take this opportunity to protect the getUser query and override its resolver.

import { Directive, Query, Resolver } from '@nestjs/graphql';

import { CurrentUser } from '../current-user.decorator';
import { User } from './user.entity';

@Resolver(of => User)
export class UsersResolver {
  @Directive('@auth')
  @Query(returns => User)
  public async getUser(@CurrentUser() user: User): Promise<User> {
    return user;
  }
}

app.module.ts :

All that’s left to do is to initialize the different modules.

We’ll leverage the transformSchema option to merge the generated schema with the AccountsJs one.

The order is important and is what allows us to extend / override some parts of the AccountsJs schema.

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { join } from 'path';
import { AccountsTypeorm, entities } from '@accounts/typeorm';
import { GraphQLModule, GqlModuleOptions } from '@nestjs/graphql';
import { Connection } from 'typeorm';
import AccountsPassword from '@accounts/password';
import AccountsServer from '@accounts/server';
import { AccountsModule, AccountsModuleContext } from '@accounts/graphql-api';
import { mergeSchemas } from 'graphql-tools';
import { GraphQLSchema } from 'graphql';

import { User } from './users/user.entity';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService): TypeOrmModuleOptions => ({
        type: configService.get<'postgres'>('TYPEORM_CONNECTION'),
        database: configService.get<string>('TYPEORM_DATABASE'),
        username: configService.get<string>('TYPEORM_USERNAME'),
        password: configService.get<string>('TYPEORM_PASSWORD'),
        host: configService.get<string>('TYPEORM_HOST'),
        port: configService.get<number>('TYPEORM_PORT'),
        entities: [join(__dirname, '**', '*.entity.{ts,js}'), ...entities],
      }),
    }),
    GraphQLModule.forRootAsync({
      inject: [Connection, ConfigService],
      useFactory: async (
        connection: Connection,
        configService: ConfigService,
      ): Promise<GqlModuleOptions> => {
        const accountsTypeorm = new AccountsTypeorm({
          connection,
          userEntity: User,
        });

        const accountsPassword = new AccountsPassword<User>({
          twoFactor: {
            appName: configService.get<string>('APP_NAME'),
          },
        });

        const accountsServer = new AccountsServer(
          {
            db: accountsTypeorm,
            tokenSecret: configService.get<string>('TOKEN_SECRET'),
            useInternalUserObjectSanitizer: false,
            enableAutologin: true,
            ambiguousErrorMessages: false,
            impersonationAuthorize: () => Promise.resolve(false),
          },
          {
            password: accountsPassword,
          },
        );

        const accountsGraphQL = AccountsModule.forRoot({ accountsServer });

        return {
          context: async ({ req }): Promise<AccountsModuleContext> =>
            await accountsGraphQL.context({ req }),
          transformSchema: (schema: GraphQLSchema): GraphQLSchema =>
            mergeSchemas({
              schemas: [accountsGraphQL.schema, schema],
            }),
          schemaDirectives: {
            ...accountsGraphQL.schemaDirectives,
          },
          autoSchemaFile: join(__dirname, 'schema.gql'),
        };
      },
    }),
    UsersModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

Run yarn start:dev and if I didn’t screw up, you should be able to access the GraphQL Playground at http://localhost:3000/graphql.

Use the following mutation to register

mutation {
  createUser(user: { username: "test", email: "test@test.com", password: "supersecret" }) {
    userId
    loginResult {
      sessionId
      tokens {
        accessToken
        refreshToken
      }
    }
  }
}

The try to run the following query with and without an Authorization header.

query  {
  getUser {
    id
    username
    avatar
    emails {
      address
    }
  }
}

Extending the User

Let’s add more fields to our User :

user.entity.ts :

import { Column, Entity } from 'typeorm';
import { Field, ObjectType } from '@nestjs/graphql';
import { User as AccountsUser } from '@accounts/typeorm';

@Entity()
@ObjectType()
export class User extends AccountsUser {
  @Column({ default: '' })
  public firstName: string;

  @Column({ default: '' })
  public lastName: string;

  @Field({ nullable: true })
  @Column({ nullable: true })
  public avatar: string;

  @Field()
  public get name(): string {
    return `${this.firstName} ${this.lastName}`;
  }
}

We’ll then generate and execute the migration :

yarn typeorm migration:generate -n AddUserInfo \
  && yarn typeorm migration:run

We’ll also modify the UsersResolver to add a field resolver for the avatar :

users.resolver.ts :

import { Directive, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';

import { CurrentUser } from '../current-user.decorator';
import { User } from './user.entity';

@Resolver(of => User)
export class UsersResolver {
  @Directive('@auth')
  @Query(returns => User)
  public getUser(@CurrentUser() user: User): User {
    return user;
  }

  @ResolveField()
  public avatar(@Parent() user: User): string {
    return user.avatar ?? 'https://via.placeholder.com';
  }
}

Depending on your registration process, you might want to extend the CreateUserInput :

app.module.ts :

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm';
import { join } from 'path';
import { AccountsTypeorm, entities } from '@accounts/typeorm';
import { GraphQLModule, GqlModuleOptions } from '@nestjs/graphql';
import { Connection } from 'typeorm';
import AccountsPassword from '@accounts/password';
import AccountsServer from '@accounts/server';
import { AccountsModule, AccountsModuleContext } from '@accounts/graphql-api';
import { mergeSchemas } from 'graphql-tools';
import { GraphQLSchema } from 'graphql';

import { User } from './users/user.entity';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService): TypeOrmModuleOptions => ({
        type: configService.get<'postgres'>('TYPEORM_CONNECTION'),
        database: configService.get<string>('TYPEORM_DATABASE'),
        username: configService.get<string>('TYPEORM_USERNAME'),
        password: configService.get<string>('TYPEORM_PASSWORD'),
        host: configService.get<string>('TYPEORM_HOST'),
        port: configService.get<number>('TYPEORM_PORT'),
        entities: [join(__dirname, '**', '*.entity.{ts,js}'), ...entities],
      }),
    }),
    GraphQLModule.forRootAsync({
      inject: [Connection, ConfigService],
      useFactory: async (
        connection: Connection,
        configService: ConfigService,
      ): Promise<GqlModuleOptions> => {
        const accountsTypeorm = new AccountsTypeorm({
          connection,
          userEntity: User,
        });

        const accountsPassword = new AccountsPassword<User>({
          twoFactor: {
            appName: configService.get<string>('APP_NAME'),
          },
          validateNewUser: ({ username, email, password, ...rest }) => {
            const { profile } = rest;

            // Your validation logic.

            return {
              username,
              email,
              password,
              ...profile
            };
          }
        });

        const accountsServer = new AccountsServer(
          {
            db: accountsTypeorm,
            tokenSecret: configService.get<string>('TOKEN_SECRET'),
            useInternalUserObjectSanitizer: false,
            enableAutologin: true,
            ambiguousErrorMessages: false,
            impersonationAuthorize: () => Promise.resolve(false),
          },
          {
            password: accountsPassword,
          },
        );

        const accountsGraphQL = AccountsModule.forRoot({ accountsServer });

        return {
          context: async ({ req }): Promise<AccountsModuleContext> =>
            await accountsGraphQL.context({ req }),
          transformSchema: (schema: GraphQLSchema): GraphQLSchema =>
            mergeSchemas({
              schemas: [accountsGraphQL.schema, schema],
              typeDefs: `
                extend input CreateUserInput {
                  profile: CreateUserProfileInput!
                }
                
                input CreateUserProfileInput {
                  firstName: String!
                  lastName: String!
                  avatar: String
                }
              `,
            }),
          schemaDirectives: {
            ...accountsGraphQL.schemaDirectives,
          },
          autoSchemaFile: join(__dirname, 'schema.gql'),
        };
      },
    }),
    UsersModule,
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

Conclusion

This might not be the perfect approach for this integration but it fits the constraints of my current project and I really hope it would help anyone playing around with a similar setup.

As always, your feedback is more than welcome.

Thanks for you time.


Elie picture

What didn’t you like about this approach?

Anas Taha picture

Nothing I disliked really, I just feels hacky and I'm pretty sure there are issues I didn't hit.

I also faced a couple of hiccups while setting up federation.

Soumyajit Pathak picture

I have used Nest.js in production before and it is pretty good. Would love to see more content on it here. Thanks Anas. :)

Anas Taha picture

It's a pleasure to see someone else appreciate NestJS, thanks for your feedback.