Dynamic types and fields

You can use different versions of your GraphQL schema for each operation. To do this, implement visible?(context) on the parts of your schema that will be conditionally accessible. Additionally, many schema elements have definition methods which are called at runtime by GraphQL-Ruby. You can re-implement those to return any valid schema objects. GraphQL-Ruby caches schema elements for the duration of the operation, but if you’re making external service calls to implement the methods below, consider adding a cache layer to improve the client experience and reduce load on your backend.

At runtime, ensure that only one object is visible per name (type name, field name, etc.). (If .visible?(context) returns false, then that part of the schema will be hidden for the current operation.)

When using dynamic schema members, be sure to include the relevant context: ... when generating schema definition files.

Different fields

You can customize which field definitions are used for each operation.

Using #visible?(context)

To serve different fields to different clients, implement def visible?(context) in your base field class:

class Types::BaseField < GraphQL::Schema::Field
  def initialize(*args, for_staff: false, **kwargs, &block)
    super(*args, **kwargs, &block)
    @for_staff = for_staff
  end

  def visible?(context)
    super && case @for_staff
    when true
      !!context[:current_user]&.staff?
    when false
      !context[:current_user]&.staff?
    else
      true
    end
  end
end

Then, you can configure fields with for_staff: true|false:

field :comments, Types::Comment.connection_type, null: false,
  description: "Comments on this blog post",
  resolver_method: :moderated_comments,
  for_staff: false

field :comments, Types::Comment.connection_type, null: false,
  description: "Comments on this blog post, including unmoderated comments",
  resolver_method: :all_comments,
  for_staff: true

With that configuration, post { comments { ... } } will use def moderated_comments when context[:current_user] is nil or is not .staff?, but when context[:current_user].staff? is true, it will use def all_comments instead.

Using .fields(context) and .get_field(name, context)

To customize the set of fields used at runtime, you can implement def self.fields(context) in your type classes. It should return a Hash of { String => GraphQL::Schema::Field }.

Along with this, you should implement .get_field(name, context) to return a field for name, if it should exist. For example:

class Types::User < Types::BaseObject
  def self.fields(context)
    all_fields = super
    if !context[:current_user]&.staff?
      all_fields.delete("isSpammy") # this is staff-only
    end
    all_fields
  end

  def self.get_field(name, context)
    field = super
    if field.graphql_name == "isSpammy" && !context[:current_user]&.staff?
      nil # don't show this field to non-staff
    else
      field
    end
  end
end

Hidden Return Types

Besides field visibility described above, if an field’s return type is hidden (that is, it implements self.visible?(context) to return false), then the field will be hidden too.

Different arguments

As with fields, you can use different sets of argument definitions for different GraphQL operations.

Using #visible?(context)

To serve different arguments to different clients, implement def visible?(context) in your base argument class:

class Types::BaseArgument < GraphQL::Schema::Argument
  def initialize(*args, for_staff: false, **kwargs, &block)
    super(*args, **kwargs, &block)
    @for_staff = for_staff
  end

  def visible?(context)
    super && case @for_staff
    when true
      !!context[:current_user]&.staff?
    when false
      !context[:current_user]&.staff?
    else
      true
    end
  end
end

Then, you can configure arguments with for_staff: true|false:

field :user, Types::User, null: true, description: "Look up a user" do
  # Require a UUID-style ID from non-staff clients:
  argument :id, ID, required: true, for_staff: false
  # Support database primary key lookups for staff clients:
  argument :id, ID, required: false, for_staff: true
  argument :database_id, Int, required: false, for_staff: true
end

def user(id: nil, database_id: nil)
  # ...
end

That way, any staff client will have the option of id or databaseId while non-staff clients must use id.

Using def arguments(context) and def get_argument(name, context)

Also, you can implement def arguments(context) on your base field class to return a Hash of { String => GraphQL::Schema::Argument } and def get_argument(name, context) to return a GraphQL::Schema::Argument or nil. . If you take this approach, you might want some custom field classes for any types or resolvers that use these methods. That way, you don’t have to reimplement the method for all the fields in the schema.

Hidden Input Types

Besides argument visibility described above, if an argument’s input type is hidden (that is, it implements self.visible?(context) to return false), then the argument will be hidden too.

Different enum values

Using #visible?(context)

You can implement def visible?(context) in your base enum value class to hide some enum values from some clients. For example:

class BaseEnumValue < GraphQL::Schema::EnumValue
  def initialize(*args, for_staff: false, **kwargs, &block)
    super(*args, **kwargs, &block)
    @for_staff = for_staff
  end

  def visible?(context)
    super && case @for_staff
    when true
      !!context[:current_user]&.staff?
    when false
      !context[:current_user]&.staff?
    else
      true
    end
  end
end

With this base class, you can configure some enum values to be just for staff or non-staff viewers:

class AccountStatus < Types::BaseEnum
  value "ACTIVE"
  value "INACTIVE"
  # Use this for sensitive account statuses when the viewer is public:
  value "OTHER", for_staff: false
  # Staff-only sensitive account statuses:
  value "BANNED", for_staff: true
  value "PAYMENT_FAILED", for_staff: true
  value "PENDING_VERIFICATION", for_staff: true
end

Using .enum_values(context)

Alternatively, you can implement def self.enum_values(context) in your enum types to return an Array of GraphQL::Schema::EnumValues. For example, to return a dynamic set of enum values:

class ProjectStatus < Types::BaseEnum
  def self.enum_values(context = {})
    # Fetch the values from the database
    status_names = context[:tenant].project_statuses.pluck("name")

    # Then build an Array of Enum values
    status_names.map do |name|
      # Be sure to include `owner: self`, the back-reference from the EnumValue to its parent Enum
      GraphQL::Schema::EnumValue.new(name, owner: self)
    end
  end
end

Different types

You can also use different types for each query. A few behaviors depend on the methods defined above:

As you can imagine, these different hiding behaviors influence one another and they can cause some real head-scratchers when used simultaneously.

Using .visible?(context)

Type classes can implement def self.visible?(context) to hide themselves at runtime:

class Types::BanReason < Types::BaseEnum
  # Hide any arguments or fields that use this enum
  # unless the current user is staff
  def self.visible?(context)
    super && !!context[:current_user]&.staff?
  end

  # ...
end

Different definitions for the same type

You can provide different implementations of the same type by:

For example, to migrate your Money scalar to a Money object type:

# Previously, we used a simple string to describe money:
class Types::LegacyMoney < Types::BaseScalar
  # This graphql name will conflict with `Types::Money`,
  # so we have to be careful not to use them at the same time.
  # (GraphQL-Ruby will raise an error if it finds two definitions with the same name at runtime.)
  graphql_name "Money"
  describe "A string describing an amount of money."

  # Use this type definition if the current request
  # explicitly opted in to the legacy money representation:
  def self.visible?(context)
    !!context[:requests_legacy_money]
  end
end

# But we want to improve the client experience with a dedicated object type:
class Types::Money < Types::BaseObject
  field :amount, Integer, null: false
  field :currency, Types::Currency, null: false

  # Use this new definition if the client
  # didn't explicitly ask for the legacy definition:
  def self.visible?(context)
    !context[:requests_legacy_money]
  end
end

Then, hook the definitions up to the schema using field definitions:

class Types::BaseField < GraphQL::Schema::Field
  def initialize(*args, legacy_money: false, **kwargs, &block)
    super(*args, **kwargs, &block)
    @legacy_money = legacy_money
  end

  def visible?(context)
    super && (@legacy_money ? !!context[:requests_legacy_money] : !context[:requests_legacy_money])
  end
end

class Types::Invoice < Types::BaseObject
  # Add one definition for each possible return type
  # (one definition will be hidden at runtime)
  field :amount, Types::LegacyMoney, null: false, legacy_money: true
  field :amount, Types::Money, null: false, legacy_money: false
end

Input types (like input objects, scalars, and enums) work the same way with argument definitions.

Schema Dumps

To dump a certain version of the schema, provide the applicable context: ... to Schema.to_definition. For example:

# Legacy money schema:
MySchema.to_definition(context: { requests_legacy_money: true })

or

# Staff-only schema:
MySchema.to_definition(context: { current_user: OpenStruct.new(staff?: true) })

That way, the given context will be passed to visible?(context) calls and other relevant methods.