GraphQL API

You can use SOULs with the GraphQL API. This guide teaches you how to do that.

Node & Edge

We start by looking at the commands related to Nodes and Edges. Let's try getting the data for an Article and a User in our example application.

First, start the application with:

souls s

Then, access the GraphQL Playground at:

localhost:4000/playground

And run the following query:

query {
  articles {
    totalCount
    totalPages
    edges {
      node {
        id
        title
        body
        user {
          id
          username
        }
        isPublic
        createdAt
        updatedAt
      }
    }
    nodes {
      id
    }
    pageInfo {
      hasNextPage
    }
  }
}

すると以下のようなレスポンスが返ってきました。

{
  "data": {
    "articles": {
      "totalCount": 100,
      "totalPages": 2,
      "edges": [
        {
          "node": {
            "id": "QXJ0aWNsZToxMDA=",
            "title": "The Proper Study",
            "body": "It is not the responsibility of the language to force good looking code, but the language should make good looking code possible.",
            "user": {
              "id": "VXNlcjoz",
              "username": "長田 帆矩"
            },
            "isPublic": false,
            "createdAt": "2021-07-07T09:00:38+02:00",
            "updatedAt": "2021-07-07T09:00:38+02:00"
          }
        },
        {
          "node": {
            "id": "QXJ0aWNsZTo5OQ==",
            "title": "All the King's Men",
            "body": "Ruby inherited the Perl philosophy of having more than one way to do the same thing. I inherited that philosophy from Larry Wall, who is my hero actually. I want to make Ruby users free. I want to give them the freedom to choose.",
            "user": {
              "id": "VXNlcjo1",
              "username": "村松 雪恵"
            },
            "isPublic": false,
            "createdAt": "2021-07-07T09:00:38+02:00",
            "updatedAt": "2021-07-07T09:00:38+02:00"
          }
        },

With this one query, we can get the data for the Article and the User data that's attached to it. When we want to get data from multiple models, we use connections. The default BaseConnection is always included.

edges: [ArticleEdge]
nodes: [Article]
pageInfo: PageInfo!
totalCount: Int!
totalPages: Int!

You can customise this if you need to. Connections are defined inside of apps/api/app/graphql/connections.

apps/api/app/graphql/connections/article_connection.rb
class Types::ArticleConnection < Types::BaseConnection
  edge_type(Types::ArticleEdge)
end

Edges are defined in apps/api/app/graphql/types/edges/

apps/api/app/graphql/types/edges/article_edge.rb
module Types
  class ArticleEdge < Types::BaseEdge
    node_type(Types::ArticleType)
  end
end

These files are autogenerated with the souls command. There's no need to edit them manually.

Type

Types define database columns. Let's look at apps/api/app/graphql/types's user_type.rb

apps/api/app/graphql/types/user_type.rb
module Types
  class UserType < BaseObject
    implements GraphQL::Types::Relay::Node

    global_id_field :id
    field :birthday, String, null: true
    field :created_at, GraphQL::Types::ISO8601DateTime, null: true
    field :email, String, null: true
    field :first_name, String, null: true
    field :first_name_kana, String, null: true
    field :first_name_kanji, String, null: true
    field :icon_url, String, null: true
    field :last_name, String, null: true
    field :last_name_kana, String, null: true
    field :last_name_kanji, String, null: true
    field :screen_name, String, null: true
    field :tel, String, null: true
    field :uid, String, null: true
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: true
    field :username, String, null: true
  end
end

In this file, the User model's columns are defined. The global_id_field is a function of GraphQL. The SOULs framework defines IDs as a globally unique encoded string, annd these are managed automatically. To learn more about the global_id_field, please follow the link below.

global_id_field

Fields are defined by name, type and null state respectively.。

Query

We use queries to get data from models. Queries are either singular or plural.

Query - Single Record

To fetch a single record, we use a singular query.

apps/api/app/graphql/queries/user.rb
module Queries
  class User < Queries::BaseQuery
    type Types::UserType, null: false
    argument :id, String, required: true

    def resolve(args)
      _, data_id = SOULsApiSchema.from_global_id(args[:id])
      ::User.find(data_id)
    rescue StandardError => e
      GraphQL::ExecutionError.new(e)
    end
  end
end

This User Query fetchees a single record by ID. The application fetches by UUID, fetching the correct record based on its ID in the database.

apps/api/app/graphql/queries/user.rb
_, data_id = SOULsApiSchema.from_global_id(args[:id])

The following three file types accomplish the work of a standard CRUD API:

Queries,

Resolvers,

ConnectionType

These are generated automaticaclly from the DB schema.

You can also create queries that are independent of standard CRUD operations:

apps/api/app/graphql/types/base/query_type.rb
field :me, resolver: Queries::Me

This line will connect directly to the code defined in apps/api/app/graphql/types/base/query_type.rb.

Mutation

There are four types of mutation: create, update, delete, destroy_delete

These files are created by default in apps/api/app/graphql/mutations/base.

Of the four CRUD operations (create, read, update, delete), the operations that relate to displaying data are managed by queries, whereas the operations that relate to modifying data become mutations.

Mutation - Create

This is the mutation for create_user.rb

apps/api/app/graphql/mutations/base/create_user.rb
module Mutations
  module Base::User
    class CreateUser < BaseMutation
      field :error, String, null: true
      field :user_edge, Types::UserType.edge_type, null: false

      argument :birthday, String, required: false
      argument :email, String, required: false
      argument :first_name, String, required: false
      argument :first_name_kana, String, required: false
      argument :first_name_kanji, String, required: false
      argument :icon_url, String, required: false
      argument :last_name, String, required: false
      argument :last_name_kana, String, required: false
      argument :last_name_kanji, String, required: false
      argument :screen_name, String, required: false
      argument :tel, String, required: false
      argument :uid, String, required: false
      argument :username, String, required: false

      def resolve(args)
        data = ::User.new(args)
        raise(StandardError, data.errors.full_messages) unless data.save

        { user_edge: { node: data } }
      rescue StandardError => e
        GraphQL::ExecutionError.new(e)
      end
    end
  end
end

The response is defined by the fields

In this case, the response is either a Types::UserType.edge_type or an error. The edge_type is used specify the pattern with which to respond to the front end Relay.

Relay

We specify the form of the argument, and whether or not it is required.

The argumets are passed to the resolver, which returns a user_edge when the data is saved. If there's a problem, a GraphQL error is returned.

Let's try registering a new user. Use the following command to start the server

souls s

And visit the following:

localhost:4000/playground

To send the following sample request.

mutation {
  createUser(
    input: {
      username: "Daan"
      email: "te@mail.com"
      uid: "test-id"
      }
  ) {
    userEdge {
      node {
        id
        username
        email
      }
    }
  }
}

When it's successful, the following response will be returned.

{
  "data": {
    "createUser": {
      "userEdge": {
        "node": {
          "id": "VXNlcjoyNg==",
          "username": "Daan",
          "email": "te@mail.com"
        }
      }
    }
  }
}

With this, we've entered a user record into the database.

Mutation - Update

Mutation - Logical Delete

Mutation - Physical Delete

Resolver

When used on a real application, we don't want to retrieve whole tables at once, but only return the data we want. In this case, we can use a resolver.

GraphQL has a plugin called search_object. If you haven't used it before, you can view the documentation at the following link:

GitHub: "search_object"

SOULs plugins are defined in apps/api/app.rb.

apps/api/app.rb
require "search_object"
require "search_object/plugin/graphql"

SOULs frameworks are defined in the diretory apps/api/app/graphql/resolver.

The generic name for folders is: ./app/grahpql/resolvers/${CLASS_NAME}_search.rb

Looking inside user_search.rb:

apps/api/app/graphql/resolvers/user_search.rb
module Resolvers
  class UserSearch < Base
    include SearchObject.module(:graphql)
    scope { ::User.all }
    type Types::UserType.connection_type, null: false
    description "Search User"

    class UserFilter < ::Types::BaseInputObject
      argument :OR, [self], required: false
      argument :birthday, String, required: false
      argument :email, String, required: false
      argument :end_date, String, required: false
      argument :first_name, String, required: false
      argument :first_name_kana, String, required: false
      argument :first_name_kanji, String, required: false
      argument :icon_url, String, required: false
      argument :is_deleted, Boolean, required: false
      argument :last_name, String, required: false
      argument :last_name_kana, String, required: false
      argument :last_name_kanji, String, required: false
      argument :screen_name, String, required: false
      argument :start_date, String, required: false
      argument :tel, String, required: false
      argument :uid, String, required: false
      argument :username, String, required: false
    end

    option :filter, type: UserFilter, with: :apply_filter
    option :first, type: types.Int, with: :apply_first
    option :skip, type: types.Int, with: :apply_skip

    def apply_filter(scope, value)
      branches = normalize_filters(value).inject { |acc, elem| acc.or(elem) }
      scope.merge(branches)
    end

    def normalize_filters(value, branches = [])
      scope = ::User.all
      scope = scope.where(uid: value[:uid]) if value[:uid]
      scope = scope.where(username: value[:username]) if value[:username]
      scope = scope.where(screen_name: value[:screen_name]) if value[:screen_name]
      scope = scope.where(last_name: value[:last_name]) if value[:last_name]
      scope = scope.where(first_name: value[:first_name]) if value[:first_name]
      scope = scope.where(last_name_kanji: value[:last_name_kanji]) if value[:last_name_kanji]
      scope = scope.where(first_name_kanji: value[:first_name_kanji]) if value[:first_name_kanji]
      scope = scope.where(last_name_kana: value[:last_name_kana]) if value[:last_name_kana]
      scope = scope.where(first_name_kana: value[:first_name_kana]) if value[:first_name_kana]
      scope = scope.where(email: value[:email]) if value[:email]
      scope = scope.where(tel: value[:tel]) if value[:tel]
      scope = scope.where(icon_url: value[:icon_url]) if value[:icon_url]
      scope = scope.where(birthday: value[:birthday]) if value[:birthday]
      scope = scope.where(is_deleted: value[:is_deleted]) unless value[:is_deleted].nil?
      scope = scope.where("created_at >= ?", value[:start_date]) if value[:start_date]
      scope = scope.where("created_at <= ?", value[:end_date]) if value[:end_date]

      branches << scope

      value[:OR].inject(branches) { |acc, elem| normalize_filters(elem, acc) } if value[:OR].present?

      branches
    end
  end
end

We define the received paramaters as argument, and you can define parameters for return such as pagination as option.

Let's get user data using a Resolver.

Sample query:

query {
  userSearch(filter: { isDeleted: false }) {
    edges {
      node {
        id
        username
        email
        isDeleted
      }
    }
  }
}

We define isDeleted: false as a filter. With this, users where is_deleted is false are returned. When it succeeds, the following response is returned.

{
  "data": {
    "userSearch": {
      "edges": [
        {
          "node": {
            "id": "VXNlcjoyNg==",
            "username": "Daan",
            "email": "te@mail.com",
            "isDeleted": false
          }
        },
        {
          "node": {
            "id": "VXNlcjoyNQ==",
            "username": "Daan",
            "email": "tedsdst@mail.com",
            "isDeleted": false
          }
        },

Model

SOULs API uses ActiveRecord. If you've used Ruby you're probably familiar with this, but if not you can find out more at this link.

Database tables are represented by Models in ./app/models by default.

SOULs Framework also inlcudes RoleModel by default, so you can assign permissions to your users.

From here, we can look at the User model.

apps/api/app/models/user.rb
class User < ActiveRecord::Base
  include RoleModel
  has_many :article

  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  private_constant :VALID_EMAIL_REGEX
  validates :email, presence: true, uniqueness: true, format: { with: VALID_EMAIL_REGEX }

  roles :normal, :user, :admin, :master

  before_create :assign_initial_roles

  # Scope
  default_scope -> { order(created_at: :desc) }

  def assign_initial_roles
    roles << [:normal]
  end
end

has_many :article connects it with a foreign key to the Article model. validates requires the email to be unique. By default, SOULs includes the roles :normal, :user, :admin and :master.