File: dynamic_types.md

package info (click to toggle)
ruby-graphql 2.2.17-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 9,584 kB
  • sloc: ruby: 67,505; ansic: 1,753; yacc: 831; javascript: 331; makefile: 6
file content (311 lines) | stat: -rw-r--r-- 10,992 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
---
layout: guide
doc_stub: false
search: true
section: Schema
title: Dynamic types and fields
desc: Using different schema members for each request
index: 8
---

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](#schema-dumps).

## 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 {% internal_link "base field class", "/type_definitions/extensions#customizing-fields" %}:

```ruby
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`:

```ruby
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:

```ruby
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 {% internal_link "base argument class", "/type_definitions/extensions#customizing-arguments" %}:

```ruby
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`:

```ruby
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" | api_doc }} 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 {% internal_link "base enum value class", "/type_definitions/extensions#customizing-enum-values" %} to hide some enum values from some clients. For example:

```ruby
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:

```ruby
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::EnumValue" | api_doc }}s. For example, to return a dynamic set of enum values:

```ruby
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:

- If a type is not used as a return type, an argument type, or as a member of a union or implementor of an interface, it will be hidden
- If an interface or union has members, it will be hidden
- If a field's return type is hidden, the field will be hidden
- If an argument's input type is hidden, the argument will be hidden

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:

```ruby
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:

- Implementing `def self.visible?(context)` to return `true` and `false` in complementary contexts. (They should never both be `.visible? => true`).
- Hooking the types up to the schema with different field or argument definitions, as described above

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

```ruby
# 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:

```ruby
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" | api_doc }}. For example:

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

or

```ruby
# 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.