Skip to content

Commit 3ddf36b

Browse files
authored
Merge pull request #5074 from rmosolgo/named-visibility
Add cached, named Visibility profiles
2 parents c615edf + aa90ecc commit 3ddf36b

32 files changed

+433
-171
lines changed

.github/workflows/ci.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ jobs:
99
- uses: actions/checkout@v4
1010
- uses: ruby/setup-ruby@v1
1111
with:
12-
ruby-version: 2.7
12+
ruby-version: 3.3
1313
bundler-cache: true
1414
- run: bundle exec rake rubocop
1515
system_tests:

guides/authorization/visibility.md

+58-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ Here are some reasons you might want to hide parts of your schema:
1818

1919
## Hiding Parts of the Schema
2020

21-
You can customize the visibility of parts of your schema by reimplementing various `visible?` methods:
21+
To start limiting visibility of your schema, add the plugin:
22+
23+
```ruby
24+
class MySchema < GraphQL::Schema
25+
# ...
26+
use GraphQL::Schema::Visibility # see below for options
27+
end
28+
```
29+
30+
Then, you can customize the visibility of parts of your schema by reimplementing various `visible?` methods:
2231

2332
- Type classes have a `.visible?(context)` class method
2433
- Fields and arguments have a `#visible?(context)` instance method
@@ -30,6 +39,31 @@ These methods are called with the query context, based on the hash you pass as `
3039
- In introspection, the member will _not_ be included in the result
3140
- In normal queries, if a query references that member, it will return a validation error, since that member doesn't exist
3241

42+
## Visibility Profiles
43+
44+
You can use named profiles to cache your schema's visibility modes. For example:
45+
46+
```ruby
47+
use GraphQL::Schema::Visibility, profiles: {
48+
# mode_name => example_context_hash
49+
public: { public: true },
50+
beta: { public: true, beta: true },
51+
internal_admin: { internal_admin: true }
52+
}
53+
```
54+
55+
Then, you can run queries with `context[:visibility_profile]` equal to one of the pre-defined profiles. When you do, GraphQL-Ruby will use a precomputed set of types and fields for that query.
56+
57+
### Preloading profiles
58+
59+
By default, GraphQL-Ruby will preload all named visibility profiles when `Rails.env.production?` is present and true. You can manually set this option by passing `use ... preload: true` (or `false`). Enable preloading in production to reduce latency of the first request to each visibility profile. Disable preloading in development to speed up application boot.
60+
61+
### Dynamic profiles
62+
63+
When you provide named visibility profiles, `context[:visibility_profile]` is required for query execution. You can also permit dynamic visibility for queries which _don't_ have that key set by passing `use ..., dynamic: true`. You could use this to support backwards compatibility or when visibility calculations are too complex to predefine.
64+
65+
When no named profiles are defined, all queries use dynamic visibility.
66+
3367
## Object Visibility
3468

3569
Let's say you're working on a new feature which should remain secret for a while. You can implement `.visible?` in a type:
@@ -107,3 +141,26 @@ end
107141
```
108142

109143
For big schemas, this can be a worthwhile speed-up.
144+
145+
## Migration Notes
146+
147+
{% "GraphQL::Schema::Visibility" | api_doc %} is a _new_ implementation of visibility in GraphQL-Ruby. It has some slight differences from the previous implementation ({% "GraphQL::Schema::Warden" | api_doc %}):
148+
149+
- `Visibility` speeds up Rails app boot because it doesn't require all types to be loaded during boot and only loads types as they are used by queries.
150+
- `Visibility` supports predefined, reusable visibility profiles which speeds up queries using complicated `visible?` checks.
151+
- `Visibility` hides types differently in a few edge cases:
152+
- Previously, `Warden` hide interface and union types which had no possible types. `Visibility` doesn't check possible types (in order to support performance improvements), so those types must return `false` for `visible?` in the same cases where all possible types were hidden. Otherwise, that interface or union type will be visible but have no possible types.
153+
- Some other thing, see TODO
154+
- When `Visibility` is used, several (Ruby-level) Schema introspection methods don't work because the caches they draw on haven't been calculated (`Schema.references_to`, `Schema.union_memberships`). If you're using these, please get in touch so that we can find a way forward.
155+
156+
### Migration Mode
157+
158+
You can use `use GraphQL::Schema::Visibility, ... migration_errors: true` to enable migration mode. In this mode, GraphQL-Ruby will make visibility checks with _both_ `Visibility` and `Warden` and compare the result, raising a descriptive error when the two systems return different results. As you migrate to `Visibility`, enable this mode in test to find any unexpected discrepancies.
159+
160+
Sometimes, there's a discrepancy that is hard to resolve but doesn't make any _real_ difference in application behavior. To address these cases, you can use these flags in `context`:
161+
162+
- `context[:visibility_migration_running] = true` is set in the main query context.
163+
- `context[:visibility_migration_warden_running] = true` is set in the _duplicate_ context which is passed to a `Warden` instance.
164+
- If you set `context[:skip_migration_error] = true`, then no migration error will be raised for that query.
165+
166+
You can use these flags to conditionally handle edge cases that should be ignored in testing.

guides/schema/dynamic_types.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ desc: Using different schema members for each request
88
index: 8
99
---
1010

11-
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.
11+
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.
12+
13+
14+
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.
1215

1316
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.)
1417

lib/graphql/query.rb

+37-8
Original file line numberDiff line numberDiff line change
@@ -95,21 +95,24 @@ def selected_operation_name
9595
# @param root_value [Object] the object used to resolve fields on the root type
9696
# @param max_depth [Numeric] the maximum number of nested selections allowed for this query (falls back to schema-level value)
9797
# @param max_complexity [Numeric] the maximum field complexity for this query (falls back to schema-level value)
98-
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil, use_schema_subset: nil)
98+
# @param visibility_profile [Symbol]
99+
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, visibility_profile: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil, use_visibility_profile: nil)
99100
# Even if `variables: nil` is passed, use an empty hash for simpler logic
100101
variables ||= {}
101102
@schema = schema
102103
@context = schema.context_class.new(query: self, values: context)
103104

104-
if use_schema_subset.nil?
105-
use_schema_subset = warden ? false : schema.use_schema_visibility?
105+
if use_visibility_profile.nil?
106+
use_visibility_profile = warden ? false : schema.use_visibility_profile?
106107
end
107108

108-
if use_schema_subset
109-
@schema_subset = @schema.subset_class.new(context: @context, schema: @schema)
109+
@visibility_profile = visibility_profile
110+
111+
if use_visibility_profile
112+
@visibility_profile = @schema.visibility.profile_for(@context, visibility_profile)
110113
@warden = Schema::Warden::NullWarden.new(context: @context, schema: @schema)
111114
else
112-
@schema_subset = nil
115+
@visibility_profile = nil
113116
@warden = warden
114117
end
115118

@@ -187,6 +190,9 @@ def query_string
187190
@query_string ||= (document ? document.to_query_string : nil)
188191
end
189192

193+
# @return [Symbol, nil]
194+
attr_reader :visibility_profile
195+
190196
attr_accessor :multiplex
191197

192198
# @return [GraphQL::Tracing::Trace]
@@ -343,10 +349,33 @@ def warden
343349
with_prepared_ast { @warden }
344350
end
345351

346-
def_delegators :warden, :get_type, :get_field, :possible_types, :root_type_for_operation
352+
def get_type(type_name)
353+
types.type(type_name) # rubocop:disable Development/ContextIsPassedCop
354+
end
355+
356+
def get_field(owner, field_name)
357+
types.field(owner, field_name) # rubocop:disable Development/ContextIsPassedCop
358+
end
359+
360+
def possible_types(type)
361+
types.possible_types(type) # rubocop:disable Development/ContextIsPassedCop
362+
end
363+
364+
def root_type_for_operation(op_type)
365+
case op_type
366+
when "query"
367+
types.query_root # rubocop:disable Development/ContextIsPassedCop
368+
when "mutation"
369+
types.mutation_root # rubocop:disable Development/ContextIsPassedCop
370+
when "subscription"
371+
types.subscription_root # rubocop:disable Development/ContextIsPassedCop
372+
else
373+
raise ArgumentError, "unexpected root type name: #{op_type.inspect}; expected 'query', 'mutation', or 'subscription'"
374+
end
375+
end
347376

348377
def types
349-
@schema_subset || warden.schema_subset
378+
@visibility_profile || warden.visibility_profile
350379
end
351380

352381
# @param abstract_type [GraphQL::UnionType, GraphQL::InterfaceType]

lib/graphql/query/null_context.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def initialize
2828
end
2929

3030
def types
31-
@types ||= GraphQL::Schema::Warden::SchemaSubset.new(@warden)
31+
@types ||= Schema::Warden::VisibilityProfile.new(@warden)
3232
end
3333
end
3434
end

lib/graphql/schema.rb

+48-31
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,9 @@ def static_validator
317317
GraphQL::StaticValidation::Validator.new(schema: self)
318318
end
319319

320+
# Add `plugin` to this schema
321+
# @param plugin [#use] A Schema plugin
322+
# @return void
320323
def use(plugin, **kwargs)
321324
if kwargs.any?
322325
plugin.use(self, **kwargs)
@@ -334,8 +337,9 @@ def plugins
334337
# @return [Hash<String => Class>] A dictionary of type classes by their GraphQL name
335338
# @see get_type Which is more efficient for finding _one type_ by name, because it doesn't merge hashes.
336339
def types(context = GraphQL::Query::NullContext.instance)
337-
if use_schema_visibility?
338-
return Visibility::Subset.from_context(context, self).all_types_h
340+
if use_visibility_profile?
341+
types = Visibility::Profile.from_context(context, self)
342+
return types.all_types_h
339343
end
340344
all_types = non_introspection_types.merge(introspection_system.types)
341345
visible_types = {}
@@ -362,17 +366,19 @@ def types(context = GraphQL::Query::NullContext.instance)
362366
end
363367

364368
# @param type_name [String]
369+
# @param context [GraphQL::Query::Context] Used for filtering definitions at query-time
370+
# @param use_visibility_profile Private, for migration to {Schema::Visibility}
365371
# @return [Module, nil] A type, or nil if there's no type called `type_name`
366-
def get_type(type_name, context = GraphQL::Query::NullContext.instance)
367-
if use_schema_visibility?
368-
return Visibility::Subset.from_context(context, self).type(type_name)
372+
def get_type(type_name, context = GraphQL::Query::NullContext.instance, use_visibility_profile = use_visibility_profile?)
373+
if use_visibility_profile
374+
return Visibility::Profile.from_context(context, self).type(type_name)
369375
end
370376
local_entry = own_types[type_name]
371377
type_defn = case local_entry
372378
when nil
373379
nil
374380
when Array
375-
if context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Subset)
381+
if context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Profile)
376382
local_entry
377383
else
378384
visible_t = nil
@@ -398,7 +404,7 @@ def get_type(type_name, context = GraphQL::Query::NullContext.instance)
398404

399405
type_defn ||
400406
introspection_system.types[type_name] || # todo context-specific introspection?
401-
(superclass.respond_to?(:get_type) ? superclass.get_type(type_name, context) : nil)
407+
(superclass.respond_to?(:get_type) ? superclass.get_type(type_name, context, use_visibility_profile) : nil)
402408
end
403409

404410
# @return [Boolean] Does this schema have _any_ definition for a type named `type_name`, regardless of visibility?
@@ -430,7 +436,7 @@ def query(new_query_object = nil, &lazy_load_block)
430436
if @query_object
431437
dup_defn = new_query_object || yield
432438
raise GraphQL::Error, "Second definition of `query(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@query_object.inspect}"
433-
elsif use_schema_visibility?
439+
elsif use_visibility_profile?
434440
@query_object = block_given? ? lazy_load_block : new_query_object
435441
else
436442
@query_object = new_query_object || lazy_load_block.call
@@ -449,7 +455,7 @@ def mutation(new_mutation_object = nil, &lazy_load_block)
449455
if @mutation_object
450456
dup_defn = new_mutation_object || yield
451457
raise GraphQL::Error, "Second definition of `mutation(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@mutation_object.inspect}"
452-
elsif use_schema_visibility?
458+
elsif use_visibility_profile?
453459
@mutation_object = block_given? ? lazy_load_block : new_mutation_object
454460
else
455461
@mutation_object = new_mutation_object || lazy_load_block.call
@@ -468,7 +474,7 @@ def subscription(new_subscription_object = nil, &lazy_load_block)
468474
if @subscription_object
469475
dup_defn = new_subscription_object || yield
470476
raise GraphQL::Error, "Second definition of `subscription(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@subscription_object.inspect}"
471-
elsif use_schema_visibility?
477+
elsif use_visibility_profile?
472478
@subscription_object = block_given? ? lazy_load_block : new_subscription_object
473479
add_subscription_extension_if_necessary
474480
else
@@ -502,7 +508,7 @@ def root_type_for_operation(operation)
502508
end
503509

504510
def root_types
505-
if use_schema_visibility?
511+
if use_visibility_profile?
506512
[query, mutation, subscription].compact
507513
else
508514
@root_types
@@ -521,37 +527,43 @@ def warden_class
521527

522528
attr_writer :warden_class
523529

524-
def subset_class
525-
if defined?(@subset_class)
526-
@subset_class
527-
elsif superclass.respond_to?(:subset_class)
528-
superclass.subset_class
530+
# @api private
531+
def visibility_profile_class
532+
if defined?(@visibility_profile_class)
533+
@visibility_profile_class
534+
elsif superclass.respond_to?(:visibility_profile_class)
535+
superclass.visibility_profile_class
529536
else
530-
GraphQL::Schema::Visibility::Subset
537+
GraphQL::Schema::Visibility::Profile
531538
end
532539
end
533540

534-
attr_writer :subset_class, :use_schema_visibility, :visibility
535-
536-
def use_schema_visibility?
537-
if defined?(@use_schema_visibility)
538-
@use_schema_visibility
539-
elsif superclass.respond_to?(:use_schema_visibility?)
540-
superclass.use_schema_visibility?
541+
# @api private
542+
attr_writer :visibility_profile_class, :use_visibility_profile
543+
# @api private
544+
attr_accessor :visibility
545+
# @api private
546+
def use_visibility_profile?
547+
if defined?(@use_visibility_profile)
548+
@use_visibility_profile
549+
elsif superclass.respond_to?(:use_visibility_profile?)
550+
superclass.use_visibility_profile?
541551
else
542552
false
543553
end
544554
end
545555

546556
# @param type [Module] The type definition whose possible types you want to see
557+
# @param context [GraphQL::Query::Context] used for filtering visible possible types at runtime
558+
# @param use_visibility_profile Private, for migration to {Schema::Visibility}
547559
# @return [Hash<String, Module>] All possible types, if no `type` is given.
548560
# @return [Array<Module>] Possible types for `type`, if it's given.
549-
def possible_types(type = nil, context = GraphQL::Query::NullContext.instance)
550-
if use_schema_visibility?
561+
def possible_types(type = nil, context = GraphQL::Query::NullContext.instance, use_visibility_profile = use_visibility_profile?)
562+
if use_visibility_profile
551563
if type
552-
return Visibility::Subset.from_context(context, self).possible_types(type)
564+
return Visibility::Profile.from_context(context, self).possible_types(type)
553565
else
554-
raise "Schema.possible_types is not implemented for `use_schema_visibility?`"
566+
raise "Schema.possible_types is not implemented for `use_visibility_profile?`"
555567
end
556568
end
557569
if type
@@ -571,7 +583,7 @@ def possible_types(type = nil, context = GraphQL::Query::NullContext.instance)
571583
introspection_system.possible_types[type] ||
572584
(
573585
superclass.respond_to?(:possible_types) ?
574-
superclass.possible_types(type, context) :
586+
superclass.possible_types(type, context, use_visibility_profile) :
575587
EMPTY_ARRAY
576588
)
577589
end
@@ -927,7 +939,7 @@ def orphan_types(*new_orphan_types)
927939
To add other types to your schema, you might want `extra_types`: https://graphql-ruby.org/schema/definition.html#extra-types
928940
ERR
929941
end
930-
add_type_and_traverse(new_orphan_types, root: false) unless use_schema_visibility?
942+
add_type_and_traverse(new_orphan_types, root: false) unless use_visibility_profile?
931943
own_orphan_types.concat(new_orphan_types.flatten)
932944
end
933945

@@ -1069,6 +1081,11 @@ def inherited(child_class)
10691081
child_class.own_trace_modes[name] = child_class.build_trace_mode(name)
10701082
end
10711083
child_class.singleton_class.prepend(ResolveTypeWithType)
1084+
1085+
if use_visibility_profile?
1086+
vis = self.visibility
1087+
child_class.visibility = vis.dup_for(child_class)
1088+
end
10721089
super
10731090
end
10741091

@@ -1186,7 +1203,7 @@ def directives(*new_directives)
11861203
# @param new_directive [Class]
11871204
# @return void
11881205
def directive(new_directive)
1189-
if use_schema_visibility?
1206+
if use_visibility_profile?
11901207
own_directives[new_directive.graphql_name] = new_directive
11911208
else
11921209
add_type_and_traverse(new_directive, root: false)

lib/graphql/schema/always_visible.rb

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# frozen_string_literal: true
22
module GraphQL
33
class Schema
4-
class AlwaysVisible
4+
module AlwaysVisible
55
def self.use(schema, **opts)
6-
schema.warden_class = GraphQL::Schema::Warden::NullWarden
7-
schema.subset_class = GraphQL::Schema::Warden::NullWarden::NullSubset
6+
schema.extend(self)
7+
end
8+
9+
def visible?(_member, _context)
10+
true
811
end
912
end
1013
end

lib/graphql/schema/argument.rb

+1
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ def load_and_authorize_value(load_method_owner, coerced_value, context)
364364

365365
# @api private
366366
def validate_default_value
367+
return unless default_value?
367368
coerced_default_value = begin
368369
# This is weird, but we should accept single-item default values for list-type arguments.
369370
# If we used `coerce_isolated_input` below, it would do this for us, but it's not really

0 commit comments

Comments
 (0)