Skip to content

Latest commit

 

History

History
771 lines (634 loc) · 26.3 KB

api-creation.md

File metadata and controls

771 lines (634 loc) · 26.3 KB

API Creation with AgGrid system

This project is backed by NestJS therefore the creation of a REST endpoint and GraphQL operations can be generally done by following the NestJS documentation creating the providers and injecting them into the related app (this is also explained in our legacy endpoint creation guide). Generally speaking, a CRUD endpoint is always composed by the following components:

  • Resolver (GraphQL API layer)
  • Controller (REST and generic API layer)
    • DTO (The Data Transfer Object that defines the structure of what you expose)
    • Dataloader
  • Service (where the business logic resides)
  • Repository (where to handle the connection with the datastores)
  • Entity (The class used for your ORM)

However, since most of the time the codebase of the providers mentioned above is similar between all the CRUD endpoints, we have created a collection of libraries called NestJS-Yalc that allows us to generate the generic logic of a CRUD endpoint by using a factory pattern. It helps to reduce the boilerplate code exponentially and avoid to unit test the same code multiple time, allowing you to focus only on the specific business logic that shouldn't/can't be generalized.

For more insights about this concept you can have a look at this slides:

https://drive.google.com/file/d/1h2Te2SZhuIp-PxElkW99YquYa_VChfH3/view?usp=sharing

Creation of CRUD operations with our Resolver factory function

Getting started

DISCLAIMERS:

  • In the code snippets below the imports are not included since they could change overtime.

  • The examples below do not include the documentation about how to create a NestJS app, how to establish a TypeORM database connection and all the steps to setup a working NestJS application. Please refer to the NestJS documentation for it.

  • The code below needs the @nestjs/graphql plugin installed with the following configurations:

  • All the examples below are contained in skeleton-module folder available in this repo. It's a fully working module that you can import in your system to test it.

This is used to avoid defining graphql @Field decorators on property types that can be detected automatically (read the NestJS doc to know more about it)

"plugins": [
      {
        "name": "@nestjs/graphql",
        "options": {
          "typeFileNameSuffix": [
            ".input.ts",
            ".entity.ts",
            ".type.ts"
          ],
          "introspectComments": true
        }
      }

To create a simple MySQL CRUD API you need 2 files: the Entity and the Resolver Factory (The DTO file is optional but strongly suggested)

The bare minimum configuration to create a CRUD is by setting up an entity and generating the needed components by using the AgGridDependencyFactory method.

So if we want to implement a CRUD API to manage a collection of phone numbers we can use the following code.

First create the entity user-phone.entity.ts

@Entity('user-phone')
@Index('unique_phone', ['phoneNumber', 'userId'], { unique: true })
@ObjectType()
@AgGridObject()
export class UserPhone extends EntityWithTimestamps(BaseEntity) {
  // if not specified elsewhere
  // ID field name will be used by default from the single
  // resource get query as argument to use to select the resource
  @PrimaryGeneratedColumn('increment')
  ID: number;

  @Column('varchar', { length: 20 })
  phoneNumber: string;

  // this will be used as a foreign key of the user table in a following example
  @Column('varchar', { length: 36 })
  userId: string;
}

Then create the related resolver user-phone.resolver.ts

export const userPhoneProvidersDeps =
  AgGridDependencyFactory<UserPhone>({
    // if DTOs are not configured, the entity will be used to
    // define the graphql types
    entityModel: UserPhone,
    // if you have a different db connection than the default one, you can set this value accordingly
    service: { dbConnection: 'default' },
     // what's the primary key used by the dataloader to load the resource when not specified
     // elsewhere
    dataloader: { databaseKey: 'ID' },
  });

The AgGridDependencyFactory will generate at runtime the Resolver, the Service, the Dataloader, the Repository and, in this case, the DTOs injecting them within the userPhoneProvidersDepsvariable that has this interface:

export interface IDependencyObject<Entity> {
  providers: Array<FactoryProvider | Provider>;
  repository: ClassType<AgGridRepository<Entity>>;
}

These 2 properties should eventually added to your TypeORM.forFeature method and the NestJS providers list available in your module.

Example:

import { userPhoneProvidersDeps } from "./user-phone.resolver.ts"

@Module({})
export class AppModule {
  static register(dbConnection: string): DynamicModule {
    return {
      module: UserModule,
      imports: [
        TypeOrmModule.forFeature(
          [userPhoneProviders.repository],
          'default',
        ),
      ],
      providers: [
        ...userPhoneProviders.providers,
      ],
    };
  }
}

NOTE: Also remember to add the UserPhone Entity to the entity list of your TypeORM configurations.

That's it! This is the bare minimum code to create a full featured GraphQL CRUD based on a single entity, the operations generated are the following:

Queries:

  • getUserPhone(ID): It returns a single UserPhone resource selected by the ID
  • getUserPhoneGrid(sorting, filters, startRow, endRow, join): It returns a list of user phones that can be sorted, filtered, paginated and joined.

Mutations:

  • createUserPhone(input): create a single UserPhone by specifying its own values within the input
  • updateUserPhone(input, conditions): update a single UserPhone by passing the input and the conditions to select the resource to update
  • deleteUserPhone(conditions): delete a single UserPhone by passing the conditions needed to select the resource to delete

Please refer to the documentation on how to use NestJS-Yalc AgGrid library to learn how to configure the query and mutation parameters properly.

NOTE: You can use our library based on graphq-sofa in order to expose RestAPI endpoints based on the queries and mutations generated above.

However, this is a very basic and non real-world example just to show you that the system is able to generate everything on his own with just a TypeORM entity.

A more advanced approach

To make the endpoints complete, we want to define more configurations to:

  • Define GraphQL nested resource with Join and Resolve Fields (Dataloader)
  • Define DTO with field visibility, graphql parameters, validation, mapping and value manipulation
  • Define endpoint authorization and decorators
  • Define options, custom names and graphql parameters for auto-generated queries/mutations
  • Define Extra arguments/input for autogenerated queries/mutations
  • Define default values for query/mutation arguments
  • Define Custom Queries and Mutations

Every single piece of the AgGridDependencyFactory is extremely configurable.

In the following example we are going to create a skeleton-user.resolver.ts file to show you all the configurations available on this system and connect this resource

Define GraphQL nested resource with Join and Resolve Fields (Dataloader)

To complete the UserPhone entity, we need to create the relationship with an User entity where we can define the owner of the phone number.

The NestJS-Yalc system integrates a feature to convert an entity property to a nested resource (ResolveField) that can be resolved via 2 strategies:

  1. Dataloader fetching: high performance and cache possibility, no data intersection available
  2. Join: lower performance but data intersection available.

To define a field as a relationship and enable the 2 features above, we should use both the TypeORM and the AgGrid decorators.

Example (skeleton-user.entity.ts):

@Entity('user')
@ObjectType()
@AgGridObject()
export class SkeletonUser extends EntityWithTimestamps(BaseEntity) {
  // guid should be always required in SQL queries to make sure that the relation
  // is always resolved, and it should be exposed as a UUID Scalar to GraphQL
  @AgGridField({ gqlType: returnValue(UUIDScalar), isRequired: true })
  @PrimaryColumn('varchar', { name: 'guid', length: 36 })
  guid: string;

  @Column('varchar')
  firstName: string;

  @Column('varchar')
  lastName: string;

  @Column('varchar', { unique: true })
  email: string;

  @Column('varchar')
  password: string;

  @AgGridField({
    dst: `CONCAT(firstName,' ', lastName)`,
    mode: 'derived',
    isSymbolic: true,
    gqlOptions: {
      description: "It's the combination of firstName and lastName",
    },
    denyFilter: true,
  })
  // virtual column, not selectable
  // handled by the @AgGridField
  @Column({
    select: false,
    insert: false,
    update: false,
    type: 'varchar',
  })
  fullName: string;

  @AgGridField<UserPhone>({
    relation: {
      type: () => UserPhone,
      relationType: 'one-to-many',
      sourceKey: { dst: 'guid' },
      targetKey: { dst: 'userId' },
    },
  })
  @OneToMany(
    () => UserPhone,
    (meta) => meta.SkeletonUser,
  )
  @JoinColumn([{ name: 'guid', referencedColumnName: 'userId' }])
  UserPhone?: UserPhone[];
}

while in the UserPhone entity (user-phone.entity.ts):

@Entity('user-phone')
@Index('unique_phone', ['phoneNumber', 'userId'], { unique: true })
@ObjectType()
@AgGridObject()
export class UserPhone extends EntityWithTimestamps(BaseEntity) {
  // if not specified elsewhere
  // ID field name will be used by default from the single
  // resource get query as argument to use to select the resource
  @PrimaryGeneratedColumn('increment')
  ID: number;

  @Column('varchar', { length: 20 })
  phoneNumber: string;

  // this will be used as a foreign key of the user table in a following example
  @Column('varchar', { length: 36 })
  userId: string;

  @AgGridField<UserPhone>({
    relation: {
      type: () => UserPhone,
      relationType: 'one-to-many',
      sourceKey: { dst: 'guid', alias: 'guid' },
      targetKey: { dst: 'userId', alias: 'userId' },
    },
  })
  @OneToOne(
    () => SkeletonUser,
    (meta) => meta.SkeletonPhone,
  )
  @JoinColumn([{ name: 'userId', referencedColumnName: 'guid' }])
  SkeletonUser?: SkeletonUser;
}

NOTE 1: most of the time you can avoid to use the @AgGridField decorator since the AgGrid system is able to automatically detect it by the TypeORM decorators, however, with the @AgGridField you can specify more options to include some edge cases. You could even only use AgGridField instead if you only need the dataloader and not the join feature.

NOTE 2: It's a good practice to define the @AgGridField within a DTO instead of an entity. It's always better to separate this 2 concepts. In the example above we used the entities for simplicity

Define DTO with field visibility, validation, mapping, and value manipulation

So far, we've seen how to generate CRUD Graphql operations by using the AgGridDependencyFactory and an Entity. However, we do not want to couple the persistence layer with the presentation layer. Therefore, we should create a DTO class to define what's the structure of the data we want to expose.

In GraphQL DTOs are represented by the GraphQL Types (response payload) and Inputs (argument payload). To define a DTO we normally want to reuse what should not be changed from the entity and redefine some properties instead (DRY approach).

We can create a skeleton-user.type.ts with the following code and move all the GraphQL related decorators within this file.

Also we have to change the @ObjectType decorator on our entity to this: @ObjectType({ isAbstract: true }) and remove the @AgGridObject one.

Example:

@ObjectType()
@AgGridObject()
export class SkeletonUserType extends SkeletonUser {
  @AgGridField<UserPhone>({
    relation: {
      type: () => UserPhone,
      relationType: 'one-to-many',
      sourceKey: { dst: 'guid', alias: 'guid' },
      targetKey: { dst: 'userId', alias: 'userId' },
    },
  })
  UserPhone?: UserPhone[];

  @HideField()
  password: string;

  // guid should be always required in SQL queries to make sure that the relation
  // is always resolved, and it should be exposed as a UUID Scalar to GraphQL
  @AgGridField({
    gqlType: returnValue(UUIDScalar),
    gqlOptions: {
      name: 'ID',
      description: 'The user ID generated with UUID',
    },
    isRequired: true,
  })
  guid: string;

  @AgGridField({
    gqlOptions: {
      description: "It's the combination of firstName and lastName",
    },
    denyFilter: true,
  })
  fullName: string;
}

/**
 * Here all the input type for Graphql
 */
@InputType()
@AgGridObject()
export class SkeletonUserCreateInput extends OmitType(
  SkeletonUserType,
  ['SkeletonPhone'] as const,
  InputType,
) {}

@InputType()
@AgGridObject({ copyFrom: SkeletonUserType })
export class SkeletonUserCondition extends PartialType(
  SkeletonUserCreateInput,
  InputType,
) {}

@InputType()
@AgGridObject({ copyFrom: SkeletonUserType })
export class SkeletonUserUpdateInput extends OmitType(
  SkeletonUserType,
  ['guid', 'SkeletonPhone'] as const,
  InputType,
) {}

In the example above we've achieved the following:

  1. We've moved most of the @AgGridField into the DTO, since they are decorators related to GraphQL. Only the configurations related to TypeORM remained in the entity.
  2. We're hiding the information about the user password because we do not want to expose such information to the final user.
  3. We're exposing the guid database field as ID in GraphQL and added the description for the graphql playground. Our system will automatically map it.
  4. We're creating some input types that will be used to define the parameters needed for our mutations, notice that we use PartialType/OmitType from the nestjs/graphql library to remove properties from the extended type that we do not need

There are many other features available NestJS-Yalc/ag-grid, including JSON field handling, middlewares, default values and many other. Please, refer to the documentation of the @AgGridField and @AgGridObject decorator to know more.

As last step, we have to define our DTO and the entity within the AgGridDependencyFactory, hence the skeleton-user.resolver.ts will look like this:

export const skeletonUserProvidersFactory = (dbConnection: string) =>
  AgGridDependencyFactory<SkeletonUser>({
    // The model used for TypeORM
    entityModel: SkeletonUser,
    resolver: {
      dto: SkeletonUserType,
      input: {
        create: SkeletonUserCreateInput,
        update: SkeletonUserUpdateInput,
        conditions: SkeletonUserCondition,
      },
    },
    service: { dbConnection },
    dataloader: { databaseKey: 'guid' },
  });

How to define authorization and decorators on autogenerated operations

One of the features that can't miss in a system with user login is the authorization of the endpoints.

You can implement a NestJS Role Guard to authorize your endpoint

This is an example of how to implement a basic Role Guard assuming that you pass the role of the user within the request context

export enum RoleEnum {
  PUBLIC,
  USER,
  ADMIN,
}

export function RoleAuth(requiredRoles: RoleEnum[]): ClassType<CanActivate> {
  @Injectable()
  class RolesGuard implements CanActivate {
    public roles: RoleEnum[] = [];
    public userId = '';

    canActivate(context: ExecutionContext): boolean {
      const ctx = GqlExecutionContext.create(context);
      const role = ctx.getContext().req.role;

      return (
        !requiredRoles.length ||
        requiredRoles.some(
          (requiredRole) =>
            requiredRole === RoleEnum.PUBLIC || requiredRole === role,
        )
      );
    }
  }

  return RolesGuard;
}

Then you can use the RoleAuth factory method to define the role needed to execute a graphql operation on your resolver file.

Example on how to apply the RoleAuth on the query:

export const skeletonUserProvidersFactory = (dbConnection: string) =>
  AgGridDependencyFactory<SkeletonUser>({
    // The model used for TypeORM
    entityModel: SkeletonUser,
    resolver: {
      dto: SkeletonUserType,
      input: {
        create: SkeletonUserCreateInput,
        update: SkeletonUserUpdateInput,
        conditions: SkeletonUserCondition,
      },
      queries: {
        // SkeletonModule_getSkeletonUser
        getResource: {
          decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
          idName: 'guid'
        },
        getResourceGrid: {
          decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))]
        },
      },
      mutations: {
        createResource: {
          decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
        },
        updateResource: {
          decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
        },
        deleteResource: {
          decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
        },
      },
    },
    service: { dbConnection },
    dataloader: { databaseKey: 'guid' }
  });

Define custom names and graphql parameters for auto-generated queries/mutations

In the example above we've seen how to specify some options for both the autogenerated queries and the mutations. By using the same properties you can define graphql parameters and many other options for each operation.

For instance:

    updateResource: {
      queryParams: {
        name: 'editUser', // instead of `updateSkeletonUser`
        description: 'Role: user. Update an existing user',
      },
    },
    deleteResource: {
      queryParams: {
        name: 'removeUser', // insted of `deleteSkeletonUser`
        description: 'Role: user. Delete an existing user',
      },
    },

The code above allows you to define a different name for every operation instead of the autogenerated one. Moreover, it specifies the description to show inside the graphql playground

Play a little bit with the available options to properly configure your operations accordingly to your needs.

Some of the available features:

  • You can set a prefix to the name of every operation by defining the prefix parameter at the first level of the resolver object.
  • You can set the readonly property to disable all the mutations or set any operations on disabled to not generate it automatically.

Define Extra arguments/input for autogenerated queries/mutations

The generated CRUD operations are already providing the default arguments to filter resources via AgGrid rules, however, there are cases where you want to define required filters or extra inputs.

To do so you can add extraArgs to queries and extraInputs to mutations

extraArgs

the extraArgs option is used to define extra filters for every query based on a strategy.

Let's say for example that we want to force to filter the list of the users by firstName or lastName (but not without any of them), we can configure the getResourceGrid in this way:

getResourceGrid: {
      decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
      extraArgs: {
        firstName: {
          filterCondition: GeneralFilters.CONTAINS,
          filterType: FilterType.TEXT,
          options: {
            type: returnValue(String),
            nullable: true,
          },
        },
        lastName: {
          filterCondition: GeneralFilters.CONTAINS,
          filterType: FilterType.TEXT,
          options: {
            type: returnValue(String),
            nullable: true,
          },
        },
      },
      extraArgsStrategy: ExtraArgsStrategy.AT_LEAST_ONE,
      queryParams: {
        // name: 'getSkeletonUserGrid',
        description: 'Get a list of users',
      },
    },
  },

As you can see in this example we're instructing the system to create 2 extra args: lastName and firstName applying the strategy AT_LEAST_ONE that means you're forced to specify at least one of the filters.

There are many more options that you can use to configure your extra arguments, including default values, hidden pre-set filters etc. Play around with the configuration to learn how to use them.

extraInputs

Similar to extraArgs, the extraInputs add arguments to your mutations but in this case they are used to extend the input arguments with extra information with the possibility as well to manipulate the default input

Example:

    createResource: {
      decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
      extraInputs: {
        lowerCaseEmail: {
          gqlOptions: {
            description: 'Force the email to be in lowercase',
            type: returnValue(Boolean),
            defaultValue: true,
            nullable: true,
          },
          middleware: (_ctx, input: SkeletonUserType, value: boolean) => {
            if (value === true) {
              input.email = input.email.toLowerCase();
            }
          },
        },
      },
      queryParams: {
        description: 'Create a new user',
      },
    },

In this example you can see that we've created an extra input called lowerCaseEmail that when it's set to true it forces the email to be in a lowercase mode by applying a middleware. The lowerCaseEmail value is also accessible by service if you need to use this value in that layer of the application (check the section below to learn how to do it)

NOTE: Obviously, this is just an example to show the potentialities.

Define Custom Queries and Mutations

So far, we've learned that AgGridDependencyFactory is able to generate highly configurable GraphQL operations. But there are cases where you can't avoid writing your own resolver or services.

Although, is always suggested to check if there's the possibility to solve your problem by using one of the available decorators or creating a custom one, in this section we will learn how to implement our own mutations and queries by using the AgGridDependencyFactory.

As stated at the beginning of this documentation, the AgGridDependencyFactory is a very flexible system, in fact you can literally replace every dependency with your own implementation of the Resolver, Service, Dataloader and Repository.

It's also important to know that NestJS-yalc/Ag-Grid provides factory methods for every of the above elements, it means that you can create your own class while still automatically generate the base to extend.

In the following example, we will create:

  • SkeletonUserService: to define some extra business logic
  • SkeletonUserResolver: to define the mutation that will use the extra business logic

Example:

export interface SkeletonUserService extends GenericService<SkeletonUser> {
  resetPassword(guid: string): Promise<string>;
}

// We are using a factory function to be able to pass the connection name dynamically
export const skeletonUserServiceFactory = (
  dbConnection: string,
): ClassType<GenericService<SkeletonUser>> => {
  @Injectable()
  class SkeletonUserService
    extends GenericService<SkeletonUser>
    implements SkeletonUserService
  {
    constructor(
      @InjectRepository(SkeletonUser, dbConnection)
      protected repository: AgGridRepository<SkeletonUser>,
    ) {
      super(repository);
    }

    async resetPassword(guid: string) {
      // create a new random password
      const newPass = Array(12)
        .fill('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
        .map(function (x) {
          return x[crypto.randomInt(0, 10_000) % x.length];
        })
        .join('');

      // update the selected user with the new password
      await this.getRepositoryWrite().update(
        { guid },
        { password: () => newPass },
      );

      // send it back to the client
      return newPass;
    }
  }

  return SkeletonUserService;
};

and then the resolver:

@Resolver(returnValue(SkeletonUserType))
export class SkeletonUserResolver extends resolverFactory({
  entityModel: SkeletonUser,
  dto: SkeletonUserType,
  input: {
    create: SkeletonUserCreateInput,
    update: SkeletonUserUpdateInput,
    conditions: SkeletonUserCondition,
  },
  prefix: 'SkeletonModule_',
  queries: {
    // SkeletonModule_getSkeletonUser
    getResource: {
      decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
      idName: 'guid',
      queryParams: {
        // name: 'getSkeletonUser',
        description: 'Get a specific user',
      },
    },
    getResourceGrid: {
      decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
      queryParams: {
        // name: 'getSkeletonUserGrid',
        description: 'Get a list of users',
      },
    },
  },
  mutations: {
    createResource: {
      decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
      queryParams: {
        // name: 'createSkeletonUser',
        description: 'Create a new user',
      },
    },
    updateResource: {
      decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
      queryParams: {
        // name: 'updateSkeletonUser',
        description: 'Update an existing user',
      },
    },
    deleteResource: {
      decorators: [UseGuards(RoleAuth([RoleEnum.PUBLIC]))],
      queryParams: {
        // name: 'deleteSkeletonUser',
        description: 'Delete an existing user',
      },
    },
  },
}) {
  constructor(
    protected service: SkeletonUserService,
    protected dataloader: GQLDataLoader,
    protected moduleRef: ModuleRef,
  ) {
    super(service, dataloader, moduleRef);
  }

  @UseGuards(RoleAuth([RoleEnum.PUBLIC]))
  @Mutation(returnValue(String), {
    description:
      'Reset user password with a random one and send the new value back.',
  })
  public async generateRandomPassword(
    @InputArgs({
      _name: 'ID',
    })
    ID: string,
  ): Promise<string> {
    return this.service.resetPassword(ID);
  }
}

lastly, we need to update the AgGridDependencyFactory in this way to inject the newly created service and resolver:

export const skeletonUserProvidersFactory = (dbConnection: string) =>
  AgGridDependencyFactory<SkeletonUser>({
    // The model used for TypeORM
    entityModel: SkeletonUser,
    resolver: {
      provider: SkeletonUserResolver,
    },

    service: {
      dbConnection: dbConnection,
      entityModel: SkeletonUser,
      provider: {
        provide: 'SkeletonUserGenericService',
        useClass: skeletonUserServiceFactory(dbConnection),
      },
    },
    dataloader: { databaseKey: 'guid' },
  });

As you've noticed, the AgGridDependencyFactory is very extensible and allows you to fulfill any needs.