Skip to content
This repository was archived by the owner on Apr 3, 2024. It is now read-only.
Erik Roberts edited this page Mar 6, 2023 · 19 revisions

Types (also called entities or resource types) are the containers for all data handled by Glimpse. Each type has at least three necessary files:

  • type_name.entity.ts - File defining the type and its properties
  • type_name.resolver.ts - GraphQL resolver for the type
  • type_name.module.ts - NestJS module that provides the resolver file

The GraphQL module will detect all resolvers provided by modules that are imported in the root module.

Type Definition

In the type_name.entity.ts file, your exported class must be annotated with @ObjectType() for GraphQL to include it in the generated schema. Similarly, each field which you want to be accessible within the schema should be annotated with the @Field decorator. This will generate the default GraphQL resolver for the field. If you instead want to implement your own resolver logic for the field, instead annotate with @HideField, and create your resolver within the type_name.resolver.ts file.

ℹ️ All fields within our GraphQL schema are nullable, even if they are not nullable within the database. This lets us selectively return properties based on a user's permissions, and simply return null if the user doesn't have permission for a field, rather than throwing an error.

In addition to the @Field annotation, you probably also want to use annotations from the class-validator package. This is an easy way to validate a user's input. Read the Input Types section for more information.

Most of our types are based on a type found within the database, and therefore we often extend Prisma's generated model of the type to allow for easy integration with Prisma. This is not required, and if your type is not stored within the database, then your class does not have to extend anything.

As a final note, all of our classes may have the static readonly property modelName, which is equal to their subject name within CASL. This is only necessary if for some reason you'd like the CASL subject name to differ from the actual class name. Otherwise, the class name itself is used as a fallback.

Resolver Definition

The type_name.resolver.ts file is the place to define additional GraphQL resolvers beyond those automatically generated via @Field annotations. There are four types of resolvers you may want to define in these files:

  • CRUD/generic resolvers - These appear in the top-level Query/Mutation/Subscription scopes of GraphQL, and are the entry point for how a user typically will interact with any given type.
  • Virtual resolvers - These are resolvers within a data type that return a value which doesn't necessarily correspond to an actual value within the data type, or is a modified version of a value within the data type.
    • For example, in the AuditLog type, we do not expose the newValue and oldValue properties directly. Instead, we have a virtual details resolver which provides a higher-level description of the changes between the two values.
  • Relational resolvers - These are similar to virtual/custom resolvers, however they are specifically for a relationship connection between this type and another type. They are responsible purely for taking an object ID and returning the object which corresponds to that ID.
    • For example, in the Production type, the field categoryId is the ID of the Category which the Production belongs to. A relational category resolver exists which returns the Category with the corresponding ID.
    • Going the other way, in the Category resolver file, there is a productions resolver which returns all Productions which have the given Category as their Category.
  • Unique/custom resolvers - These are top-level Query/Mutation/Subscription resolvers which are not CRUD resolvers. They perform some action which is closely related to the type that the current file is for.
    • For example, the self resolver within the User resolver file will return the User object that belongs to the currently logged-in user. If the user isn't logged in, it returns null.

ℹ️ This is a naming scheme used within the Glimpse project, and does not correspond to any sort of paradigm within NestJS or GraphQL. All NestJS is looking at is the annotations on your resolver classes and resolver methods.

All of these resolvers are defined within a class which is annotated with the type that those resolvers correspond to (e.g. @Resolver(() => User)). Each resolver method in the class is then annotated with what type of resolver it is: either @Query, @Mutation, or @Subscription for top-level resolvers, or @ResolveField for resolvers within the type defined in the @Resolver annotation. Top-level resolvers do not have to be in the resolver file that corresponds to their return type, however this is typically what makes most sense for the developer experience.

Input Types

The types we output to the user is only one half of the equation. We also need to define how the user inputs data into the API for creating/updating objects. This is done through input types (also called DTOs).

The folder for each type should also contain a dto folder which includes all of the input type information. Typically, this will be four files:

  • create-type_name.input.ts
  • filter-type_name.input.ts
  • order-type_name.input.ts
  • update-type_name.input.ts

These four files define various input types that are included in the generated GraphQL schema, and those types may be used within the type_name.resolver.ts files.

Create and Update

Generally, your create and update input types are going to heavily correlate with the actual entity type. NestJS's GraphQL integration provides some useful utilities to extend your GraphQL types into other types, including input types. Check out the Mapped types page in the NestJS manual for more information, however for the purpose of this article, you can think of it as a GraphQL equivalent of the extends keyword in TypeScript.

Since our input classes extend their normal entity types, the class-validator annotations carry over as well. Thus, in our createX and updateX CRUD resolvers, we can call on the class-validator to validate the inputs and make sure they match the requirements we set. If you add additional fields to your input types, you can and should annotate those with the relevant class-validator annotations as well.

ℹ️ You may recall that all of our properties have to be nullable for the non-strict permissions feature. If you want to ensure that the user's inputs are non-null, you can use the @IsNotEmpty() annotation from class-validator.

Filtering

When users call the readManyX and countX resolvers, they generally have the option to pass in filter arguments. We don't want to allow users to filter by just any field (for example, passwords), so we need to define what fields our users are allowed to filter by. Additionally, different types are going to be filterable in different ways (e.g. you can compare whether one number is greater than another, but that doesn't make sense for booleans). So, we need to also define how users are able to filter fields, based on what type they are.

Glimpse has a bunch of predefined comparison input types within the src/gql folder. There exists a file and class for numbers, strings, booleans, and Dates. Glimpse does not currently support sorting or filtering by any other data type, however this should be all you need for 99% of cases. These types are modeled after the Prisma filtering operators, allowing us to essentially pass the user's input directly into Prisma.

Within your filter input class, you will want to define each property that the user is able to filter by, as well as what type that property is. You can also allow conjoining of multiple filters via AND, OR, or NOT keywords. In the end, your class may look something like this.

@InputType()
export class FilterImageInput {
    id?: NumberComparisonInput;
    name?: StringComparisonInput;

    AND?: FilterImageInput[];
    OR?: FilterImageInput[];
    NOT?: FilterImageInput;
}

Sorting

ℹ️ We use ordering and sorting synonymously in Glimpse. The inputs were originally called order to match the SQL keyword ORDER, however we've since shifted to try to match the Prisma terminology, which uses "sort".

Similar to filtering, we don't want to allow our users to sort by just any field. Your sorting definition file should be pretty simple: an enum definition defining which fields are sortable, and then a two-property object which defines which field to sort by and which direction to sort it in. In the end, your file may look something like this.

enum ImageOrderableFields {
    id = "id",
    name = "name"
}

registerEnumType(ImageOrderableFields, {
    name: "ImageOrderableFields"
});

@InputType()
export class OrderImageInput {
    field: ImageOrderableFields;
    direction: OrderDirection;
}

Pagination

readManyX resolvers also usually support pagination. Pagination is type-agnostic, and therefore it's input type is defined within the src/gql folder.

Stream type

Currently, the Stream type is the only type within the API that does not correspond to a Prisma type. Streams are a way to forward a data stream from one RTMP host to another. This is controlled by the glimpse-video and glimpse-video-control Docker containers, available at rpitv/glimpse-video. Data is transmitted between the glimpse-video-control container and the API via a RabbitMQ server. The Stream type and its implementation is very messy and was thrown together as fast as possible. It direly needs a rewrite and could use additional features. See #78 for some more context.

Clone this wiki locally