DEV Community

Kenichi Ebinuma
Kenichi Ebinuma

Posted on

Create a GraphQL server with Go

Let's create a GraphQL server with Go.
gqlgen is a good choice to implement GraphQL server with Go.

code is here.
GitHub
This is an English version of article posted on Qiita Advent Calendar 2019.


What is GraphQL?

graphql.org

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.

GraphQL is a query language and implementation for API. At first it is developed by Facebook, and now promoted by GraphQL Foundation.
We can take many advantages like type safe request, editor completion, auto-generated documents.

What is gqlgen?

gqlgen.com

gqlgen is a Go library for building GraphQL servers without any fuss.

gqlgen is GraphQL server library that have following features.

  • Schema first
  • Type safe
  • Code generate

Schema first development is a development style. define schema -> code generate -> implement specific logic. Please check [Feature comparison] if you are interested.


Features

  • list tasks (with pagination)
  • order tasks
    • by created_at
    • by due
  • create task
  • update task

This is one example of GraphQL schema for implementing above features.

query {
  tasks(input: TasksInput!, orderBy: TaskOrderFields!, page: PaginationInput!): TaskConnection!
}

mutation {
  createTask(input: CreateTaskInput!): Task!
  updateTask(input: UpdateTaskInput!): Task!
}
type Task implements Node {
  id: ID!
  title: String!
  notes: String!
  completed: Boolean!
  due: Time!
}
type TaskEdge implements Edge {
  cursor: String!
  node: Task!
}
type TaskConnection implements Connection {
  pageInfo: PageInfo!
  edges: [TaskEdge]!
}

input TasksInput {
  completed: Boolean
}

enum TaskOrderFields {
  LATEST
  DUE
}

input CreateTaskInput {
  title: String!
  notes: String
  completed: Boolean
  due: Time
}

input UpdateTaskInput {
  taskID: ID!
  title: String
  notes: String
  completed: Boolean
  due: Time
}
type PageInfo {
  endCursor: String!
  hasNextPage: Boolean!
}

interface Connection {
  pageInfo: PageInfo!
  edges: [Edge]!
}
interface Edge {
  cursor: String!
  node: Node!
}
interface Node {
  id: ID!
}

input PaginationInput {
  first: Int
  after: String
}

Details will be explained on each implementation.

Project settings

1. Create Go environment using Docker and docker-compose.

Create Go environment by go modules and install following softwares or libraries.

FROM golang:1.13.4-alpine3.10 as build

WORKDIR /app

RUN apk update --no-cache \
  && apk add --no-cache \
    git \
    gcc \
    musl-dev

COPY go.mod .
COPY go.sum .

RUN go mod download

COPY . .

RUN GOOS=linux GOARCH=amd64 go build -o app main.go

RUN GO111MODULE=off go get github.com/oxequa/realize
RUN GO111MODULE=off go get -tags 'mysql' -u github.com/golang-migrate/migrate/cmd/migrate
---
version: '3.7'

services:
  app:
    container_name: graphql-app-backend
    build:
      context: ./app
      target: build
    volumes:
      - ./app:/app
    environment:
      DB_HOST: db
      DB_PORT: 3306
      DB_USER: root
      DB_PASSWORD: root
      DB_NAME: graphql-app-development
    ports:
      - 3000:3000
    depends_on:
      - db
    links:
      - db
    tty: true

  db:
    container_name: graphql-app-db
    image: mysql:8.0.13
    volumes:
      - ./db/mysql/data:/var/lib/mysql
      - ./db/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
    environment:
      MYSQL_USER: root
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: graphql-app-development
    ports:
      - 3306:3306
    tty: true

2. Setup Go modules and create simple HTTP server using echo

Create simple HTTP server with Go using echo. echo is High performance and minimalist Go web framework.

package main

import (
    "net/http"

    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
)

func main() {
    e := echo.New()

    e.Use(middleware.Recover())
    e.Use(middleware.Logger())
    e.Use(middleware.Gzip())

    e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
        AllowOrigins: []string{os.Getenv("CORS_ALLOW_ORIGIN")},
        AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
    }))

    e.GET("/health", func(c echo.Context) error {
        return c.NoContent(http.StatusOK)
    })

    e.HideBanner = true
    e.Logger.Fatal(e.Start(":3000"))
}

3. Setup hot reload

Add realize.yml for setting up hot reload using realize.

---
settings:
  legacy:
    force: false
    interval: 0s

schema:
  - name: app
    path: .
    commands:
      install:
        status: true
        method: go build -o app main.go
      run:
        status: true
        method: ./app
    watcher:
      extensions:
        - go
      paths:
        - /
      ignored_paths:
        - .realize

4. Create Makefile

Create Makefile for running each commands easily.

DB_HOST=db
DB_PORT=3306
DB_USER=root
DB_PASSWORD=root
DB_NAME=graphql-app-development
DB_CONN=mysql://${DB_USER}:${DB_PASSWORD}@tcp\(${DB_HOST}:${DB_PORT}\)/${DB_NAME}

.PHONY: run
run:
    docker-compose up --build -d

.PHONY: start
start:
    docker-compose exec app realize start --run

# create migration file
.PHONY: migrate-create
migrate-create:
    docker-compose exec app migrate create -ext sql -dir migrations ${FILENAME}

# run migration
.PHONY: migrate-up
migrate-up:
    docker-compose exec app migrate --source file://migrations --database ${DB_CONN} up

# run migration(rollback)
.PHONY: migrate-down
migrate-down:
    docker-compose exec app migrate --source file://migrations --database ${DB_CONN} down 1

5. Overview

File structure should be like this.

$ tree backend
.
├── .gitignore
├── Makefile
├── README.md
├── app
│   ├── .realize.yaml
│   ├── Dockerfile
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── db
│   └── mysql
│       └── my.cnf
└── docker-compose.yml

When you run make at backend directory, app service and db server will be up. And you can start server with make start command.

$ make start
docker-compose exec app realize start --run
[14:28:00][APP] : Watching 9 file/s 6 folder/s
[14:28:00][APP] : Install started
[14:28:01][APP] : Install completed in 0.748 s
[14:28:01][APP] : Running..
[14:28:02][APP] : ⇨ http server started on [::]:3000

Create tasks table

1. create and run migration fil

Next, create tasks table.
Run make migrate-create for creating migration file.

$ FILENAME=create_tasks make migrate-create

<timestamp>_create_tasks.up.sql and <timestamp>_create_tasks.down.sql will be created under migrations directory. So write up/down SQL.

CREATE TABLE tasks (
  id         INT NOT NULL AUTO_INCREMENT,
  identifier varchar(255) BINARY NOT NULL,
  title      varchar(255) NOT NULL,
  notes      text NOT NULL,
  completed  tinyint(1) NOT NULL DEFAULT 0,
  due        timestamp NULL DEFAULT NULL,
  created_at timestamp NOT NULL,
  updated_at timestamp NOT NULL,
  deleted_at timestamp NULL DEFAULT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uix_tasks_identifier (identifier)
) ENGINE=InnoDB;
DROP TABLE IF EXISTS tasks;

Run migrate-up for creating table.

$ make migrate-up

Table will be like this.

$ docker-compose exec db -it sh
sh> mysql -uroot -proot graphql-app-development
mysql> desc tasks;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| identifier | varchar(255) | NO   | UNI | NULL    |                |
| title      | varchar(255) | NO   |     | NULL    |                |
| notes      | text         | NO   |     | NULL    |                |
| completed  | tinyint(1)   | NO   |     | 0       |                |
| due        | timestamp    | YES  |     | NULL    |                |
| created_at | timestamp    | NO   |     | NULL    |                |
| updated_at | timestamp    | NO   |     | NULL    |                |
| deleted_at | timestamp    | YES  |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
9 rows in set (0.02 sec)

Set up gqlgen

You will set up gqlgen. This step is almost the same as official tutorial.
gqlgen getting started

Create project template

Run gqlgen init for creating gqlgen project template.

$ docker-compose exec app go run github.com/99designs/gqlgen init

Then following files will be created.

  • gqlgen.yml
    • settings for gqlgen
  • generated.go
    • GraphQL runtime (updated by go generate)
  • models_gen.go
    • missing model definitions like GraphQL type, input, enum. (updated by go generate)
  • resolver.go
    • GraphQL resolver (you will implement business logic for query/mutation)
  • schema.graphql
    • schema definitions
  • server/server.go
    • run server

Then edit following files.
generated.go

  • move to resolver/generated.go and rename package

models_gen.go

  • move to model/models_gen.go and rename package

Update gqlgen settings for applying new file structures.

---
schema:
  - "schema/*.graphql"

exec:
  filename: resolver/generated.go
  package: resolver

model:
  filename: model/models_gen.go
  package: model

resolver:
  filename: resolver/resolver.go
  type: Resolver

Move to resolver/resolver.go and change content as following.

//go:generate go run github.com/99designs/gqlgen

package resolver

type Resolver struct{}

type queryResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }

func New() *Resolver {
    return &Resolver{}
}

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

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

Remove unnecessary types, queries and mutations.

type Query {}

type Mutation {}

Delete server/server.go and add /graphql endpoint at main.go.

package main

import (
    "app/resolver"
    "net/http"

    "github.com/99designs/gqlgen/handler"
    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
)

func main() {
    e := echo.New()

    e.Use(middleware.Recover())
    e.Use(middleware.Logger())
    e.Use(middleware.Gzip())

    e.GET("/health", func(c echo.Context) error {
        return c.NoContent(http.StatusOK)
    })

    e.POST("/graphql", func(c echo.Context) error {
        config := resolver.Config{
          Resolvers: resolver.New(),
        }
        h := handler.GraphQL(resolver.NewExecutableSchema(config))
        h.ServeHTTP(c.Response(), c.Request())

        return nil
    })

    e.HideBanner = true
    e.Logger.Fatal(e.Start(":3000"))
}

Finally backend/app directory will be like this.

$ tree app
.
├── .realize.yaml
├── Dockerfile
├── go.mod
├── go.sum
├── gqlgen.yml
├── main.go
├── migrations
│   ├── <timestamp>_create_tasks.down.sql
│   └── <timestamp>_create_tasks.up.sql
├── model
│   └── models_gen.go
├── resolver
│   ├── generated.go
│   └── resolver.go
└── schema
    └── schema.graphql

Add generate command to Makefile

# add
.PHONY: generate
generate:
    docker-compose exec app go generate ./...

gqlgen settings are all done. You will implement queries and mutations by these steps.

  1. Add/edit schema at backend/app/schema/*.graphql
  2. Generate code by make generate
  3. Implement resolver for fill interface which is created/updated by step 2.

Create task model

You will setup db connection and create task model.

1. setup DB

Add db settings to config/db.go

package config

import (
    "fmt"
    "os"

    _ "github.com/go-sql-driver/mysql"

    "github.com/jinzhu/gorm"
    _ "github.com/jinzhu/gorm/dialects/mysql"
)

var db *gorm.DB

func InitDB() error {
    conn, err := gorm.Open("mysql", dbsn())
    if err != nil {
        return err
    }

    db = conn.Set("gorm:auto_update", false)

    return nil
}

func dbsn() string {
    return fmt.Sprintf(
        "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
        os.Getenv("DB_USER"),
        os.Getenv("DB_PASSWORD"),
        os.Getenv("DB_HOST"),
        os.Getenv("DB_PORT"),
        os.Getenv("DB_NAME"),
    )
}

func DB() *gorm.DB {
    return db
}
...
func main() {
    e := echo.New()

    if err := config.InitDB(); err != nil {
        panic(err.Error())
    }
    ...

2. Create task model

Create task model at model/task.go. And setup validation using GORM's BeforeSave hook and initialize validator at model/model.go.

package model

import "time"

type Task struct {
    ID         int
    Identifier string `validate:"required,max=255"`
    Title      string `validate:"required,max=255"`
    Notes      string `validate:"max=65535"`
    Completed  bool
    Due        *time.Time
    CreatedAt  time.Time
    UpdatedAt  time.Time
    DeletedAt  *time.Time
}

func (t *Task) BeforeSave() error {
    return validator.Struct(t)
}
package model

import (
    v "gopkg.in/go-playground/validator.v9"
)

var validator *v.Validate

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

3. Add Task type to GraphQL schema

Create Task type to schema/task.graphql.

type Task {
  id: ID!
  title: String!
  notes: String!
  completed: Boolean!
  due: Time
}

Time type is gqlgen's build-in scalar type. So you should define it.
https://gqlgen.com/reference/scalars/#time

# 追記
scalar Time

4. Connect GraphQL type and Go struct at gqlgen.yml

You can connect GraphQL type and Go struct at gqlgen.yml. GraphQL's ID type should be globally unique. So you should use identifier over id.

...
models:
  Task:
    model: app/model.Task
    fields:
      id:
        resolver: true

5. Update resolver

Apply updates on gqlgen.yml by make generate.

$ make generate

Then TaskResolver interface will be created at resolver/generated.go. So create taskResolver at resolver/task.go.
ID(ctx context.Context, obj *model.Task) method will be used for resolving id of GraphQL Task type.

package resolver

import (
    "app/model"
    "context"
)

type taskResolver struct{ *Resolver }

func (r *Resolver) Task() TaskResolver {
    return &taskResolver{r}
}

func (r *taskResolver) ID(ctx context.Context, obj *model.Task) (string, error) {
    if obj == nil {
      return "", nil
    }

    return obj.Identifier, nil
}

Implement CreateTask mutation

You will implement task creation feature. (CreateTask mutation)

1. Define schema

...
type Mutation {
  createTask(input: CreateTaskInput!): Task! # 追記
}
# 追記
input CreateTaskInput {
  title: String!
  notes: String
  completed: Boolean
  due: Time
}

You can define without input like createTask(title: String!, ...): Task!. But it is easy to read by using input. And you can easily handle request arguments because input struct will be created.

2. Implement resolver

Run make generate.

$ make generate

Then createTask will be added at MutationResolver interface. So you should create CreateTask at resolver/resolver.go.

func (r *mutationResolver) CreateTask(ctx context.Context, input model.CreateTaskInput) (*model.Task, error) {
    db := config.DB()

    id, err := config.ShortID().Generate()
    if err != nil {
        return &model.Task{}, err
    }

    task := model.Task{
        Identifier: id,
        Title:      input.Title,
        Due:        input.Due,
    }
    if input.Notes != nil {
        task.Notes = *input.Notes
    }
    if input.Completed != nil {
        task.Completed = *input.Completed
    }

    if err := db.Create(&task).Error; err != nil {
        return &model.Task{}, err
    }

    return &task, nil
}

4. Check behaviors

createTask implementation is done. Check behaviors by using clients like GraphiQL or graphql-playground.

createTask mutation

Implement UpdateTask mutation

You will implement UpdateTask mutation.

1. Define schema

Define schema same as createTask.

...
type Mutation {
  ...
  updateTask(input: UpdateTaskInput!): Task! # 追記
}
# 追記
input UpdateTaskInput {
  taskID: ID!
  title: String
  notes: String
  completed: Boolean
  due: Time
}

2. implement UpdateTask resolver

Generate code.

$ make generate

Then updateTask will be added to MutationResolver interface.

func (r *mutationResolver) UpdateTask(ctx context.Context, input model.UpdateTaskInput) (*model.Task, error) {
    db := config.DB()

    var task model.Task
    if err := db.Where("identifier = ?", input.TaskID).First(&task).Error; err != nil {
        return &model.Task{}, err
    }

    params := map[string]interface{}{}
    if input.Title != nil {
        params["title"] = *input.Title
    }
    if input.Notes != nil {
        params["notes"] = *input.Notes
    }
    if input.Completed != nil {
        params["completed"] = *input.Completed
    }
    if input.Due == nil {
        params["due"] = nil
    } else {
        params["due"] = *input.Due
    }

    if err := db.Model(&task).Updates(params).Error; err != nil {
        return &model.Task{}, err
    }

    return &task, nil
}

3. Check behaviors

updateTask implementation is done. Check behaviors by using clients like GraphiQL or graphql-playground.

updataTask mutation

Implement Tasks query

1. Overview

You will implement tasks query with pagination. When implement pagination on GraphQL, relay-style pagination is recommended. So you will implement relay-style pagination.

relay-style pagination specification

2. Define schema

Define schema.

type Query {
  tasks(input: TasksInput!, orderBy: TaskOrderFields!,  page: PaginationInput!): TaskConnection!
}
...
type Task implements Node { # add `implements Node`
...
}
# add
type TaskEdge implements Edge {
  cursor: String!
  node: Task!
}

type TaskConnection implements Connection {
  pageInfo: PageInfo!
  edges: [TaskEdge]!
}

input TasksInput {
  completed: Boolean
}

enum TaskOrderFields {
  LATEST
  DUE
}
...
type PageInfo {
  endCursor: String!
  hasNextPage: Boolean!
}

interface Connection {
  pageInfo: PageInfo!
  edges: [Edge]!
}

interface Edge {
  cursor: String!
  node: Node!
}

interface Node {
  id: ID!
}

input PaginationInput {
  first: Int
  after: String
}

2. Generate code

Run make generate.

$ make generate

3. Update task struct

Add IsNode method to task struct for implementing Node.

func (Task) IsNode() {} // add

4. Implement pagination

You will implement pagination like following steps.

  1. Decode cursor from request and get key.
  2. Create SQL.
  3. Execute SQL.
  4. Create connection from result.

You will implement pagination for ordering by created at or due. But you can not order by just targeted column.
You can order tasks by created_at by using ORDER BY id. But you can't do for due because it can be duplicated.

These are example for implementing pagination by non-unique key.

id(unique)
  • cursor
    • task:5
  • SQL
    • SELECT * FROM tasks WHERE id > 5 ORDER BY id DESC;
due(non-unique)
  • cursor
    • task:5:created_at:123456 (1234.. is unix timestamp)
  • SQL
    • SELECT * FROM tasks WHERE (UNIX_TIMESTAMP(due) < 123456) OR (UNIX_TIMESTAMP(due) = 123456 AND id < 5) ORDER BY due IS NULL ASC, id ASC;

Now you can implement resolver.

func (r *queryResolver) Tasks(ctx context.Context, input model.TasksInput, orderBy model.TaskOrderFields, page model.PaginationInput) (*model.TaskConnection, error) {
    db := config.DB()

    if input.Completed != nil {
        db = db.Where("completed = ?", *input.Completed)
    }

    var err error

    switch orderBy {
    case model.TaskOrderFieldsLatest:
        db, err = pageDB(db, "id", desc, page)
        if err != nil {
            return &model.TaskConnection{PageInfo: &model.PageInfo{}}, err
        }

        var tasks []*model.Task
        if err := db.Find(&tasks).Error; err != nil {
            return &model.TaskConnection{PageInfo: &model.PageInfo{}}, err
        }

        return convertToConnection(tasks, orderBy, page), nil
    case model.TaskOrderFieldsDue:
        db, err = pageDB(db, "UNIX_TIMESTAMP(due)", asc, page)
        if err != nil {
            return &model.TaskConnection{PageInfo: &model.PageInfo{}}, err
        }

        var tasks []*model.Task
        if err := db.Find(&tasks).Error; err != nil {
            return &model.TaskConnection{PageInfo: &model.PageInfo{}}, err
        }

        return convertToConnection(tasks, orderBy, page), nil
    default:
        return &model.TaskConnection{PageInfo: &model.PageInfo{}}, errors.New("invalid order by")
    }
}
package resolver

import (
    "app/model"
    "encoding/base64"
    "errors"
    "fmt"
    "strconv"
    "strings"

    "github.com/jinzhu/gorm"
)

type direction string

var (
    // unused: asc direction = "asc"
    desc direction = "desc"
)

func pageDB(db *gorm.DB, col string, dir direction, page model.PaginationInput) (*gorm.DB, error) {
    var limit int
    if page.First == nil {
        limit = 11
    } else {
        limit = *page.First + 1
    }

    if page.After != nil {
        resource1, resource2, err := decodeCursor(*page.After)
        if err != nil {
            return db, err
        }

        if resource2 != nil {
            switch dir {
            case asc:
                db = db.Where(
                     fmt.Sprintf("(%s > ?) OR (%s = ? AND id > ?)", col, col),
                     resource1.ID,
                     resource1.ID, resource2.ID,
                )
            case desc:
                db = db.Where(
                    fmt.Sprintf("(%s < ?) OR (%s = ? AND id < ?)", col, col),
                    resource1.ID,
                    resource1.ID, resource2.ID,
                )
            }
        } else {
            switch dir {
            case asc:
                db = db.Where(fmt.Sprintf("%s > ?", col), resource1.ID)
            case desc:
                db = db.Where(fmt.Sprintf("%s < ?", col), resource1.ID)
            }
        }
    }

    switch dir {
    case asc:
        db = db.Order(fmt.Sprintf("%s IS NULL ASC, id ASC", col))
    case desc:
        db = db.Order(fmt.Sprintf("%s DESC, id DESC", col))
    }

    return db.Limit(limit), nil
}

type cursorResource struct {
    Name string
    ID   int
}

func createCursor(first cursorResource, second *cursorResource) string {
    var cursor []byte
    if second != nil {
        cursor = []byte(fmt.Sprintf("%s:%d:%s:%d", first.Name, first.ID, second.Name, second.ID))
    } else {
        cursor = []byte(fmt.Sprintf("%s:%d", first.Name, first.ID))
    }

    return base64.StdEncoding.EncodeToString(cursor)
}

func decodeCursor(cursor string) (cursorResource, *cursorResource, error) {
    bytes, err := base64.StdEncoding.DecodeString(cursor)
    if err != nil {
        return cursorResource{}, nil, err
    }

    vals := strings.Split(string(bytes), ":")

    switch len(vals) {
    case 2:
      id, err := strconv.Atoi(vals[1])
      if err != nil {
          return cursorResource{}, nil, errors.New("invalid_cursor")
      }

      return cursorResource{Name: vals[0], ID: id}, nil, nil
    case 4:
      id, err := strconv.Atoi(vals[1])
      if err != nil {
          return cursorResource{}, nil, errors.New("invalid_cursor")
      }

      id2, err := strconv.Atoi(vals[3])
      if err != nil {
          return cursorResource{}, nil, errors.New("invalid_cursor")
      }

      return cursorResource{
          Name: vals[0],
          ID:   id,
      }, &cursorResource{
          Name: vals[2],
          ID:   id2,
      }, nil
    default:
        return cursorResource{}, nil, errors.New("invalid_cursor")
    }
}

func convertToConnection(tasks []*model.Task, orderBy model.TaskOrderFields, page model.PaginationInput) *model.TaskConnection {
    if len(tasks) == 0 {
        return &model.TaskConnection{PageInfo: &model.PageInfo{}}
    }

    pageInfo := model.PageInfo{}
    if page.First != nil {
        if len(tasks) >= *page.First+1 {
            pageInfo.HasNextPage = true
            tasks = tasks[:len(tasks)-1]
        }
    }

    switch orderBy {
    case model.TaskOrderFieldsLatest:
        taskEdges := make([]*model.TaskEdge, len(tasks))

        for i, task := range tasks {
            cursor := createCursor(
                cursorResource{Name: "task", ID: task.ID},
                nil,
            )
            taskEdges[i] = &model.TaskEdge{
                Cursor: cursor,
                Node:   task,
            }
        }

        pageInfo.EndCursor = taskEdges[len(taskEdges)-1].Cursor

        return &model.TaskConnection{PageInfo: &pageInfo, Edges: taskEdges}
    case model.TaskOrderFieldsDue:
        taskEdges := make([]*model.TaskEdge, 0, len(tasks))

        for _, task := range tasks {
          if task.Due == nil {
              pageInfo.HasNextPage = false
              return &model.TaskConnection{PageInfo: &pageInfo, Edges: taskEdges}
          }

          cursor := createCursor(
              cursorResource{Name: "task", ID: int(task.Due.Unix())},
              &cursorResource{Name: "due", ID: task.ID},
          )

          taskEdges = append(taskEdges, &model.TaskEdge{
              Cursor: cursor,
              Node:   task,
          })
        }

        pageInfo.EndCursor = taskEdges[len(taskEdges)-1].Cursor

        return &model.TaskConnection{PageInfo: &pageInfo, Edges: taskEdges}
    }

    return &model.TaskConnection{PageInfo: &model.PageInfo{}}
}

5. Check behaviors

tasks query implementation is done. Check behaviors by using clients like GraphiQL or graphql-playground.

order by latest

tasks order by latest query

order by due

tasks order by due query


You finally finished to implement GraphQL server. I hope you enjoyed schema first development on GraphQL. Implementing pagination is a little bit difficult at the last part. Code generation is one of the good solution.

Top comments (2)

Collapse
 
lemenendez profile image
leonidas menendez

Very useful, thanks

Collapse
 
roelofjanelsinga profile image
Roelof Jan Elsinga

This is exactly the post I've been looking for! Thank you for your thoroughness!