Skip to content

GraphQL types, queries and mutations

Easy8 uses the graphql-ruby gem. All queries, mutations and subscriptions belong to one schema, EasyGraphql::AppSchema (app/api/easy_graphql/app_schema.rb). graphiql-rails is mounted at /graphiql for trying queries.

Where the code goes

Core classes live in app/api/easy_graphql/. A plugin keeps its own classes in <plugin>/app/api/easy_graphql/ using the same sub-folders (types, resolvers, mutations, subscriptions). The legacy <plugin>/api/ folder is deprecated.

A plugin adds its fields to the core root types with EasyGraphql.register_patch. Require the file from a plugin initialiser, for example config/initializers/easy_api.rb:

lib/my_plugin/easy_graphql.rb
1
2
3
4
5
6
7
EasyGraphql.register_patch("EasyGraphql::Types::Mutation") do
  field :create_my_entity, mutation: EasyGraphql::Mutations::CreateMyEntity, null: true
end

EasyGraphql.register_patch("EasyGraphql::Types::Query") do
  field :my_entities, resolver: EasyGraphql::Resolvers::MyEntities
end

The same call extends an existing type, for example EasyGraphql.register_patch("EasyGraphql::Types::Issue") with an extra field. EasyGraphql.patch still works but is deprecated.

After you change the schema, regenerate the frontend schema and types: see GraphQL services and definitions.

Types

https://graphql-ruby.org/guides#type-definitions-guides

Types live in app/api/easy_graphql/types and inherit from EasyGraphql::Types::Base (types/base.rb). Declare data with field and filter arguments with argument.

ID vs. Int vs. Integer

https://graphql-ruby.org/type_definitions/scalars.html

Use ID for primary keys and relations. Use Integer for other integer values.

Enums

https://graphql-ruby.org/type_definitions/enums.html

Enums inherit from GraphQL::Schema::Enum and live in types/enums, for example EasyGraphql::Types::Enums::SortOrder. Name an enum in the singular, for example IssueStatus.

Inputs (arguments)

https://graphql-ruby.org/type_definitions/input_objects.html

An input is a hash of entity attributes, used mostly by mutations. Inputs inherit from EasyGraphql::Types::BaseInput (types/base_input.rb) and live in types/inputs. Name the file model_name_attributes.rb; the _attributes suffix distinguishes an input from a type.

Best-practice

  1. Inherit every type from Types::Base.
  2. Set the required option on every argument.
  3. Use ID for primary keys and relations, Integer for other integers.

Resolvers

https://graphql-ruby.org/fields/resolvers.html

A resolver fetches data. Inherit from EasyGraphql::Resolvers::Base (resolvers/base.rb), declare parameters with argument, the return type with type, and fetch the data in resolve.

Place resolvers in app/api/easy_graphql/resolvers. Name a resolver after what it does, or after the entity in plural when it fetches entities, for example Issues or MarkAsRead.

Best-practice

  1. Set the required option on every argument and read values through arguments.
  2. Fetch a single entity with find. ActiveRecord::RecordNotFound for a missing record is the correct outcome.
  3. Fetch only records the current user may see, for example through the visible scope.

Example

example usage of resolver
module EasyGraphql
  module Resolvers
    class IssueDetail < Resolvers::Base
      description "Return Issue object based on given ID."

      type Types::Issue, null: false

      argument :id, ID, required: true

      def resolve(**_args)
        issue
      end

      private

      def issue
        @issue ||= ::Issue.preload(journals: :details).visible.find(arguments[:id])
      end

    end
  end
end

Mutations

https://graphql-ruby.org/mutations/mutation_classes.html

A mutation changes data. Inherit from EasyGraphql::Mutations::Base (mutations/base.rb), declare inputs with argument, the returned data with field, and do the change in resolve.

Place mutations in app/api/easy_graphql/mutations. Name a mutation after what it does, or after the entity with an action prefix, for example CreateIssue, UpdateIssue or DeleteIssue.

[!CAUTION] One mutation does one action. Never combine create and update in one mutation.

Error handling

https://graphql-ruby.org/mutations/mutation_classes.html#error-handling

Errors use EasyGraphql::Types::Error (types/error.rb). Define errors on your mutation as a field:

field :errors, [Types::Error], null: true

errors must always be an array of Types::Error.

Inside a mutation, errors returns an EasyGraphql::Mutations::Errors object (mutations/errors.rb) with two methods:

app/api/easy_graphql/mutations/errors.rb
# Add an error to the errors array.
def add_error(attribute:, messages:, is_required: false)
  errors << { attribute:, messages:, is_required: }
end

# Add errors from an entity to the errors array.
def add_entity_errors(entity)
  entity_errors = EasyExtensions::ParseActiveRecordErrors.call(entity)
  errors.concat(entity_errors)
end

To save an ActiveRecord object in a mutation:

Example of resolve method with validation
entity.init_journal(User.current, "My changes")
errors.add_entity_errors(entity) unless entity.save

{ entity:, errors: }

Best-practice

  1. Prefix the mutation name with the action (Create, Update, Delete).
  2. Always return the errors field. Return an empty array when there is no error.
  3. Set the required option on every argument.
  4. Fetch an existing entity with find on a visible scope. ActiveRecord::RecordNotFound for a missing record is the correct outcome.
  5. Authorise every change. When the user lacks permission, raise Unauthorized.
  6. Call init_journal when the entity is journalised.
  7. Assign attributes with safe_attributes=.
  8. When a new entity is invalid, return errors and nil for the entity.

Mutation example

example of mutation
module EasyGraphql
  module Mutations
    # Please replace "entity" for specific object,
    # to avoid usage of mutation for multiple entities.
    class UpdateEntity < Mutations::Base
      description "Update an Entity."

      argument :id, ID, required: true
      argument :attributes, Types::Inputs::EntityAttributes, required: true

      field :entity, Types::Entity, null: false
      field :errors, [Types::Error]

      def resolve(**_args)
        raise Unauthorized unless entity.editable?

        entity.init_journal(User.current)
        entity.safe_attributes = arguments[:attributes].to_hash

        unless entity.save
          errors.add_entity_errors(entity)
        end

        { entity:, errors: }
      end

      private

      def entity
        @entity ||= Entity.visible.find(arguments[:id])
      end

    end
  end
end

Delete mutations

A delete mutation returns the least it can. Return the deleted object or its ID only when the frontend needs it; otherwise return errors alone, which tells the frontend whether the operation succeeded.

ActiveRecord#destroy succeeds even for an invalid object. A failed destroy raises ActiveRecord::RecordNotDestroyed: rescue it and return its message as an error on attribute :base.

Delete mutation example

example of delete mutation
module EasyGraphql
  module Mutations
    # Please replace "entity" for specific object,
    # to avoid usage of mutation for multiple entities.
    class DeleteEntity < Mutations::Base
      description "Destroys entity."

      argument :id, ID, required: true

      field :errors, [Types::Error], null: true

      def resolve(**_args)
        raise Unauthorized unless entity.deletable?

        entity.destroy
        { errors: }
      rescue ActiveRecord::RecordNotDestroyed => e
        errors.add_error(attribute: :base, messages: [e.message])
        { errors: }
      end

      private

      def entity
        @entity ||= Entity.visible.find(arguments[:id])
      end

    end
  end
end

Subscriptions

https://graphql-ruby.org/subscriptions/subscription_classes.html

A subscription is a channel the client opens once; the server then pushes data whenever a trigger fires. Inherit from EasyGraphql::Subscriptions::Base (subscriptions/base.rb) and place the class in app/api/easy_graphql/subscriptions. Name it after the object and the event, for example IssueUpdated.

Implement subscribe (runs once, when the client subscribes; may return initial data) and, only when needed, update (runs on every trigger; by default returns the triggered object). Do not override resolve.

Setup, registration, triggering, frontend and testing are described in Real-time updates.