You can use different versions of your GraphQL schema for each operation. To do this, add use GraphQL::Schema::Visibility
and 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.
You can customize which field definitions are used for each operation.
#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.
.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
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.
As with fields, you can use different sets of argument definitions for different GraphQL operations.
#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
.
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.
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.
#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
.enum_values(context)
Alternatively, you can implement def self.enum_values(context)
in your enum types to return an Array of GraphQL::Schema::EnumValue
s. 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
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.
.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
You can provide different implementations of the same type by:
def self.visible?(context)
to return true
and false
in complementary contexts. (They should never both be .visible? => true
).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.
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.