DEV Community

mikeyGlitz
mikeyGlitz

Posted on

Supercharge Your API Development with GraphQL and Go

Welcome to the next chapter of our GraphQL journey, where we'll delve into the intricacies of building a high-performance GraphQL API in a Go project. In our previous post, we explored key foundational elements, from creating an application configuration with envconfig to integrating a data persistence layer using Gorm and efficiently indexing data with OpenSearch. Today, we're taking this journey to the next level. We'll introduce you to the powerful gqlgen library and showcase how it effortlessly integrates GraphQL into your Go application. Building upon the project conventions we've already established, we'll define our GraphQL schema, implement resolvers, and explore the abstract world of data models, breaking them down into reusable services. But that's not all – we'll also discuss how middleware can seamlessly integrate these services, and we'll introduce schema-level validation to ensure data integrity. Furthermore, for those seeking enhanced performance and efficient data retrieval, we'll shine a spotlight on dataloaders. So fasten your seatbelts as we embark on a voyage through the fascinating realm of GraphQL and Go.

Setting up gqlgen for GraphQL Integration

To begin integrating GraphQL into your project, you will need to set up gqlgen. gqlgen is a popular Go library for automatically generating a GraphQL server based on a schema definition. It provides a seamless way to build robust and type-safe GraphQL APIs. Here's how you can install and initialize gqlgen:

Start by adding the following line to your tools.go file:

import _ "github.com/99designs/gqlgen"
Enter fullscreen mode Exit fullscreen mode

Next, run the initialization script for gqlgen using the following commands:

go mod tidy
go run github.com/99designs/gqlgen init
Enter fullscreen mode Exit fullscreen mode

Running gqlgen init will generate the gqlgen.yml file, which serves as the base configuration for gqlgen. It will also create a graph folder containing the necessary files and folders for GraphQL. Some important artifacts that are generated include schema.graphqls, which contains the GraphQL schema definition for your API, and the generated and model folders, which provide generated structs and functions to support your GraphQL implementation.

With the gqlgen skeleton established, let's customize it to fit the Go project convention we've set up in the previous entry of this series.

Customizing gqlgen for the Go Project Convention

Begin by moving the graph folder into the internal folder. This ensures that the generated code follows the project's internal structure.

Next, we need to update the gqlgen configuration by modifying the gqlgen.yml file. We want gqlgen to recognize the updated graph folder and generate the code accordingly. Here's an example of the updated configuration:

# Where are all the schema files located?
schema:
  - internal/graph/schemas/*.graphqls

# Where should the generated server code go?
exec:
  filename: internal/graph/generated/generated.go
  package: generated

# Where should the generated models go?
model:
  filename: internal/graph/model/models_gen.go
  package: model

# Where should the resolver implementations go?
resolver:
  layout: follow-schema
  dir: internal/graph
  package: graph

# Other gqlgen configuration values
Enter fullscreen mode Exit fullscreen mode

The modified configuration allows gqlgen to recognize multiple .graphqls files located in the internal/graph/schemas directory, enabling better compartmentalization of the schema.

Defining the GraphQL Schema

In GraphQL, we need to define several primary elements: model types, connection types, input types for CRUD operations, and resolvers. Let's focus on an example for the Ingredient type:

type Ingredient {
  id: ID!
  name: String!
  measurement: Measurement!
  createdAt: Time!
  updatedAt: Time!
}

type IngredientList {
  ingredients: [Ingredient!]
  lastItem: String!
  hits: Int!
}

input NewIngredient {
  name: String!
  measurement: String!
}

input UpdateIngredient {
  name: String
  measurement: String
}

type IngredientOps {
  update(id: String!, input: UpdateIngredient!): Ingredient! @goField(forceResolver: true)
  delete(id: String!): Ingredient! @goField(forceResolver: true)
}
Enter fullscreen mode Exit fullscreen mode

The above snippet represents the Ingredient type in our schema. The schema file for Ingredient would be located at internal/graph/schemas/ingredient.graphqls. It includes definitions for the Ingredient type itself, an IngredientList type used for retrieving multiple records, input types for insertions and updates, and a type to compartmentalize mutations related to Ingredient.

By defining these elements in the schema, we establish the structure and behavior of our GraphQL API for the Ingredient type.

Once all the data model types are defined, it's time to integrate them with the root schema, which is located in the schema.graphqls file.

directive @goField(
  forceResolver: Boolean
  name: String
) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION

scalar Time

type Query {
  ingredient(id: String!): Ingredient!
  ingredients(name: String): IngredientList!
}

type Mutation {
  ingredient: IngredientOps! @goField(forceResolver: true)
}
Enter fullscreen mode Exit fullscreen mode

In the schema, we define a custom scalar Time, which represents a time.Time object in Go within the context of our GraphQL schema. This scalar is built-in and supported by gqlgen.

GraphQL specifies three root types: Query, Mutation, and Subscription. In our example, we focus on Query and Mutation. The Query type is responsible for data retrieval operations, while the Mutation type handles updates to the data in the schema. We won't be using the Subscription type for our API server in this example.

gqlgen provides a built-in directive called @goField, which allows us to override the default behavior in GraphQL. We can use @goField to force specific fields to be treated as resolvers, enabling us to compartmentalize our mutations and customize their behavior as needed.

We can update the generated code by executing the following command:

go run github.com/99designs/gqlgen generate
Enter fullscreen mode Exit fullscreen mode

This command triggers gqlgen to regenerate the necessary code based on the schema and configuration files. It ensures that any changes made to the schema or configuration are reflected in the generated code, providing an up-to-date implementation for our GraphQL API.

Implementing Resolvers

After executing the gqlgen generate command, you will notice several new files in the internal/graph folder. Each model defined in the previous section will have a corresponding .resolvers.go file, which contains the implementation of the schema resolvers. Initially, these files will contain stubbed-out resolver methods.

For example, let's consider the integration of mutations with the Ingredient type:

package graph

func (r *ingredientOpsResolver) Update(ctx context.Context, obj *model.IngredientOps, id string, input map[string]interface{}) (*model.Ingredient, error) {
    // Resolver implementation here
}

type IngredientOpsResolver struct {
    *Resolver
}
Enter fullscreen mode Exit fullscreen mode

In the above snippet, we have the resolver implementation for the Update mutation of the Ingredient type. Additionally, a schema.resolvers.go file is generated, which includes resolver functions for both queries and mutations:

package graph

func (r *queryResolver) Ingredient(ctx context.Context, id string) (model.Ingredient, error) {
    // Resolver implementation here
}

func (r *mutationResolver) Ingredient(ctx context.Context) (*model.IngredientOps, error) {
    return &model.IngredientOps{}, nil
}

func (r *Resolver) Mutation() generated.MutationResolver {
    return &mutationResolver{r}
}

func (r *Resolver) Query() generated.QueryResolver {
    return &queryResolver{r}
}

type mutationResolver struct {
    *Resolver
}

type queryResolver struct {
    *Resolver
}
Enter fullscreen mode Exit fullscreen mode

In the schema.resolvers.go file, we have resolver methods for the Ingredient query and the Ingredient mutation. These methods will be implemented with the required functionality to handle the corresponding GraphQL operations.

Overall, after generating the code with gqlgen, the resolver files provide a starting point for implementing the actual logic for the resolvers, allowing you to customize the behavior of your GraphQL API.

Abstracting the Data Model: Implementing Services

While we could proceed to call our data model methods directly from the resolvers we just generated, as a general best practice, we'll wrap our data model with a layer of abstraction called services. This approach provides several benefits and allows us to maintain separation of concerns. In the internal/graph/services folder, we'll create a service for each data model defined in the previous article.

Each service will define an interface, IIngredientService in the case of the Ingredient model, which outlines the operations that can be performed on that specific data model. This interface serves as a contract and provides a generic abstraction for our resolvers. By working with interfaces, we can easily change the implementation of each service without impacting the resolvers. This flexibility comes in handy, especially when writing unit tests, where we can swap out a service implementation for a mock implementation.

In the example of the IngredientService, we define two package aliases: dbmodel refers to the database model package created with gorm, and apimodel refers to the generated GraphQL model created with gqlgen. The IngredientService struct holds the necessary dependencies such as the database connection (DB), the OpenSearch connection (ES), and the context (Context).

Within the IngredientService, we implement the methods defined in the IIngredientService interface. For instance, the Create method takes input from the GraphQL API, creates a new dbmodel.Ingredient instance, and saves it to the database using svc.DB.Create(). We can also perform additional logic or transformations as needed before returning the result converted to the GraphQL model (apimodel.Ingredient).

package services

type IIngredientService interface {
    Create(input apimodel.NewIngredient) (*apimodel.Ingredient, error)
    Update(id string, input map[string]interface{}) (*apimodel.Ingredient, error)
    Delete(id string) (*apimodel.Ingredient, error)
    FetchIngredient(id string) (*apimodel.Ingredient, error)
    FetchIngredients(id string) (*apimodel.IngredientList, error)
}

type IngredientService struct {
    DB      *gorm.DB
    ES      index.OpenSearchConnection
    Context context.Context
}

func (svc *IngredientService) Create(input apiModel.NewIngredient) (*apimodel.Ingredient, error) {
    ingredient := dbmodel.Ingredient{
        Name:        input.Name,
        Measurement: input.Measurement,
    }

    if err := svc.DB.Create(&ingredient); err != nil {
        log.Errorf("[IngredientService] could not save Ingredient => %v", err)
        return nil, err
    }

    return ingredient.ConverToGraphQLModel(), nil
}

// Implement remaining service methods

type Services struct {
    UserService IUserService
}
Enter fullscreen mode Exit fullscreen mode

The Services struct acts as a container for all the defined services, providing a convenient way to manage and access them in our application.

By introducing this services layer, we achieve better organization, maintainability, and testability of our codebase.

Integrating Services Using Middleware

With our services implemented, the next step is to integrate them into our GraphQL implementation through the use of middleware. We'll leverage the HTTP server framework, gin-gonic, to set up the middleware. To begin, let's create a new folder called internal/middleware, which will contain our middleware files. Within this folder, we'll create a file named services.middleware.go. In this file, we'll define a function called Services that will serve as the middleware function, enhancing the server context with the services we defined earlier. Additionally, we'll create a function named ForServices that will be used to fetch the services from the server context.

package middleware

var serviceKey = &contextKey{name: "services"}

// Services enhances the request context with the services
func Services(db *gorm.DB, es index.OpensearchConnection) gin.HandlerFunc {
    return func(c *gin.Context) {
        services := services.Services{
            UserService:       &services.UserService{DB: db, ES: es, Context: c.Request.Context()},
            PantryService:     &services.PantryService{DB: db, ES: es, Context: c.Request.Context()},
            IngredientService: &services.IngredientService{DB: db, ES: es, Context: c.Request.Context()},
        }
        // Enhance the context with the services
        c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), serviceKey, &services))
        c.Next()
    }
}

// ForServices is used to retrieve the service directory from the context
func ForServices(ctx context.Context) *services.Services {
    return ctx.Value(serviceKey).(*services.Services)
}
Enter fullscreen mode Exit fullscreen mode

The Services function is a gin.HandlerFunc that takes a database connection (db) and an OpenSearch connection (es) as parameters. Within this function, we create instances of the services we defined earlier, passing the necessary dependencies. The middleware then enhances the request context by adding the services to it using context.WithValue(). The ForServices function retrieves the services from the context based on the serviceKey.

Within the context of our resolvers, we can now leverage our service implementation. For example, let's consider the implementation of an ingredient resolver:

func (r *ingredientOpsResolver) CreateIngredient(ctx context.Context, obj *apimodel.IngredientOps, input apimodel.NewIngredient) (*apimodel.Ingredient, error) {
    services := getServices(ctx)
    return services.IngredientService.Create(input)
}
Enter fullscreen mode Exit fullscreen mode

In this code snippet, we define the resolver method CreateIngredient which takes the necessary context, the object being resolved (obj), and the input data (input). By calling getServices(ctx), we retrieve the services from the context. With the services available, we can then invoke the Create method of the IngredientService to add a new ingredient based on the provided input. This integration of services within our resolvers allows us to easily interact with the underlying data models and perform the necessary operations.

Schema-Level Validation

To ensure the stability and security of your API, it is crucial not to blindly trust user input. One of the primary steps in handling incoming requests is validating the request body. By validating the request body, you can ensure that only expected and valid requests are processed, while also safeguarding your API against potential malicious code or attacks.

Validating the request body serves as a defense mechanism to filter out any inappropriate or malformed input that could potentially compromise the integrity and security of your system. It allows you to verify the data's format, check for required fields, enforce data constraints, and perform any necessary sanitization or normalization.

By implementing robust input validation mechanisms, you establish a strong line of defense against common security vulnerabilities such as injection attacks, cross-site scripting (XSS), and other forms of malicious exploits. It helps to mitigate the risk of unauthorized access, data breaches, and manipulation of sensitive information.

In addition to ensuring security, proper input validation also contributes to the overall stability and reliability of your API. By rejecting invalid or unexpected requests early in the process, you can prevent errors, inconsistencies, and potential crashes that could occur when handling malformed data. This improves the overall robustness and performance of your API.

Transitioning to the built-in validations provided by GraphQL, you can leverage the native validation capabilities of the GraphQL specification itself. GraphQL offers a range of predefined validation rules that can be applied to your schema and queries, ensuring data integrity and consistency.

By utilizing these built-in validations, you can further enhance the validation process within your API. The GraphQL validation rules not only validate the incoming request body but also leverage the GraphQL schema to ensure the validity of the entire operation.

The GraphQL schema acts as a blueprint for your API, defining the types, fields, and relationships that your API supports. During the validation process, GraphQL's built-in validations compare the structure and semantics of the incoming request against the schema. This schema-based validation ensures that the requested fields, arguments, and operations conform to the defined types and their respective constraints.

By incorporating the schema in the validation process, you benefit from a powerful mechanism that checks not only the basic syntax but also the semantic correctness of the request. The validation rules analyze the query's structure, field usage, argument requirements, input object validation, and more, ensuring that the request aligns with the schema's expectations.

The benefit of using GraphQL's built-in validations, which are schema-aware, is that they provide a standardized and declarative approach to ensure data quality. The validations are performed before executing the resolver functions, guaranteeing that the incoming request aligns with the schema's definitions.

This schema-based validation approach offers several advantages. First, it allows you to catch and handle potential issues early on, reducing the chances of executing invalid or inconsistent operations. Second, it enables you to provide meaningful and precise error messages to clients, guiding them in making valid requests by highlighting specific violations against the schema. Third, it helps maintain the consistency and coherence of your API's responses by ensuring that the data returned adheres to the defined schema and meets the expected structure.

Moreover, GraphQL's validation rules are extensible, allowing you to add custom validation logic tailored to your specific business requirements. This flexibility enables you to enforce additional business rules, perform complex validations, and integrate with any existing validation frameworks or libraries, all while leveraging the schema as the foundation for validation.

We will extend GraphQL's built-in validation logic by introducing a custom directive that enables field-level validation, allowing us to impose additional constraints on specific fields. To begin, we'll define the new directive in our schemas.graphqls file, as shown in the snippet below:

directive @validate(constraint: String!) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
Enter fullscreen mode Exit fullscreen mode

This directive, named validate, will be utilized on query arguments and fields within our input types. By applying this directive, we can specify a constraint that must be satisfied by the input provided for the annotated field.

With the validate directive established in the schema, the next step is to provide the business logic for the directive. Gqlgen requires directive logic to be located in the graph/directives directory. We'll create a file named validate.go which will contain the implementation of the validate directive.

The validator implementation will make use of the go-playground/validator package. This package provides several common validators that can be applied using Go struct-tags. To get started, we will create a package-level initialization function init() and a public function Validate() as shown below:

package directives

import (
    "context"
    "github.com/go-playground/validator/v10"
    "github.com/graph-gophers/graphql-go"
)

var (
    validate *validator.Validate
)

func init() {
    validate = validator.New()
}

func Validate(ctx context.Context, obj interface{}, next graphql.Resolver, constraint string) (interface{}, error) {
    val, err := next(ctx)
    if err != nil {
        panic(err)
    }

    err = validate.Var(val, constraint)
    if err != nil {
        return nil, err
    }

    return val, nil
}
Enter fullscreen mode Exit fullscreen mode

The function Validate() effectively applies the specified constraint to validate the val using the validate package. The init() function initializes the validate package's validator, which is crucial for this validation process.

The introduction of validator directives enriches our GraphQL schema by allowing us to enforce field-level constraints. By leveraging the go-playground/validator package, we can efficiently validate input data with predefined constraints. These directives add a layer of security and data integrity, ensuring that only valid data is accepted. What's particularly advantageous is that directives offer a modular and compartmentalized structure, making it easy to apply and reuse validation rules across different parts of the schema. This modular approach promotes maintainability and consistency, as validation logic is separated from resolver functions. It's a powerful way to ensure that your GraphQL API adheres to business rules and data integrity requirements without cluttering your resolver code.

Faster Data Retrievers with dataloaden

Having covered how to create custom directives like validate to enforce validation rules within our GraphQL schema, let's now delve into another important aspect of GraphQL server optimization and data handling: data loaders. While validators ensure the integrity of the input data, data loaders are crucial for efficiently fetching and aggregating data from various sources in response to GraphQL queries. GraphQL provides a powerful mechanism for efficient data loading known as dataloaders. These data loaders help mitigate the N+1 query problem by batching and caching database queries, significantly enhancing the performance and efficiency of your GraphQL APIs. In the next section, we'll explore how to implement data loaders in our GraphQL server and optimize data retrieval.

This is where dataloaden comes in. Dataloaden is a Go package designed to streamline and optimize data loading in GraphQL servers. It generates Go code for dataloaders, which are a crucial part of GraphQL query performance. With dataloaden, you can efficiently load related data, minimize database queries, and provide faster responses to GraphQL queries. It's a popular tool for enhancing the performance of your GraphQL APIs and ensuring a smooth experience for clients when handling complex queries.

With the concept of what the dataloaden library accomplishes established, we can now explore how we put it to practical use within our project. In our dataloaders package, we utilize the generated Loader struct to create data loaders.

Begin by creating a new package under internal called dataloaders and create a new file called dataloders.go which will contain the following go code to create a new IngredientLoader implementation.

package dataloaders

//go:generate go run github.com/vektah/dataloaden IngredientLoader string *internal/graph/model.Ingredient

// Loaders - directory for all the data loaders to be stored in context
type Loaders struct {
    // Ingredient - dataloader responsible for fetching ingredient objects
    Ingredient IngredientLoader
}
Enter fullscreen mode Exit fullscreen mode

The //go:generate statement is a special comment in Go source code that is used to trigger code generation. In this code snippet, it is instructing the Go toolchain to run the specified go run command when code generation is needed.

In this specific case, the //go:generate statement is running the dataloaden command from the github.com/vektah/dataloaden package. This command generates code for a data loader named IngredientLoader. The data loader is responsible for efficiently fetching ingredient objects based on keys of type string. It is generated to work with the internal/graph/model.Ingredient model.

Additionally, the code defines a Loaders struct, which serves as a directory to store all data loaders within the context of the GraphQL server. This allows easy access to various data loaders for efficient data retrieval in GraphQL resolvers.

To integrate the dataloaders with our request handling process, we employ a middleware mechanism. The DataloaderMiddleware function, designed as a Gin middleware, serves as a pivotal bridge. This middleware extracts the necessary services and creates instances of dataloaders, such as our IngredientLoader, utilizing the dataloaders.Loaders struct.

func DataloaderMiddleware() gin.HandlerFunc{
    return func(c *gin.Context) {
        services := ForServices(c.Request.Context())
        loaders := dataloaders.Loaders{
            Ingredient: *dataloaders.NewIngredientLoader(dataloaders.IngredientLoaderConfig{
                Wait:     waitTime,
                MaxBatch: maxBatch,
                Fetch: func(ids []string) ([]*apimodel.Ingredient, []error) {
                    ingredients := make([]*apimodel.Ingredient, len(ids))
                    errors := make([]error, len(ids))
                    for i, id := range ids {
                        ingredient, err := services.IngredientService.FetchIngredient(id)
                        if err != nil {
                            errors[i] = err
                            continue
                        }
                        ingredients[i] = ingredient
                    }
                    return ingredients, errors
                },
            }),
        }
        c.Request = c.Request.WithContext(context.WithValue(c.Request.Context(), loadersKey, &loaders))
        c.Next()
    }
}

// ForLoader - fetches the loader directory from context
func ForLoader(ctx context.Context) *dataloaders.Loaders {
    return ctx.Value(loadersKey).(*dataloaders.Loaders)
}
Enter fullscreen mode Exit fullscreen mode

As the middleware associates the dataloaders with the request context, it ensures that our resolvers can efficiently utilize these dataloaders to retrieve data when needed.

In this manner, our GraphQL server can seamlessly harness the power of dataloaders to optimize its performance, making batched data retrieval a streamlined process. This middleware-driven approach ensures that the dataloaders are readily available throughout the request lifecycle, creating a high-performance GraphQL server ready to serve client requests efficiently.

Summary

In conclusion, our journey through GraphQL integration and optimization has provided a holistic view of how to build a powerful and efficient GraphQL API in a Go project. We began by setting up gqlgen and customizing it to follow project conventions, ensuring a seamless integration. Defining the GraphQL schema and implementing resolvers brought our API to life, while the abstraction of the data model into services enhanced flexibility and maintainability. Middleware facilitated the integration of services, while built-in schema validation ensured the integrity of incoming requests. Finally, we tackled performance optimization with dataloaders, effectively mitigating the N+1 query problem. These topics, when combined, have equipped us with the knowledge and tools to create a robust, high-performance GraphQL server that aligns with Go project conventions. As we move forward, we'll explore additional facets of API development, building upon the strong foundation we've established. So stay tuned for more exciting insights in the next installment of our journey.

In our next post, we'll explore integrating GraphQL with the HTTP server Gin, and we'll enhance server security by implementing Keycloak as an authentication service.

References

Top comments (0)