Skip to content

Commit

Permalink
Merge pull request #5074 from rmosolgo/named-visibility
Browse files Browse the repository at this point in the history
Add cached, named Visibility profiles
  • Loading branch information
rmosolgo authored Oct 10, 2024
2 parents c615edf + aa90ecc commit 3ddf36b
Show file tree
Hide file tree
Showing 32 changed files with 433 additions and 171 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: 2.7
ruby-version: 3.3
bundler-cache: true
- run: bundle exec rake rubocop
system_tests:
Expand Down
59 changes: 58 additions & 1 deletion guides/authorization/visibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ Here are some reasons you might want to hide parts of your schema:

## Hiding Parts of the Schema

You can customize the visibility of parts of your schema by reimplementing various `visible?` methods:
To start limiting visibility of your schema, add the plugin:

```ruby
class MySchema < GraphQL::Schema
# ...
use GraphQL::Schema::Visibility # see below for options
end
```

Then, you can customize the visibility of parts of your schema by reimplementing various `visible?` methods:

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

## Visibility Profiles

You can use named profiles to cache your schema's visibility modes. For example:

```ruby
use GraphQL::Schema::Visibility, profiles: {
# mode_name => example_context_hash
public: { public: true },
beta: { public: true, beta: true },
internal_admin: { internal_admin: true }
}
```

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.

### Preloading profiles

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.

### Dynamic profiles

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.

When no named profiles are defined, all queries use dynamic visibility.

## Object Visibility

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

For big schemas, this can be a worthwhile speed-up.

## Migration Notes

{% "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 %}):

- `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.
- `Visibility` supports predefined, reusable visibility profiles which speeds up queries using complicated `visible?` checks.
- `Visibility` hides types differently in a few edge cases:
- 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.
- Some other thing, see TODO
- 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.

### Migration Mode

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.

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

- `context[:visibility_migration_running] = true` is set in the main query context.
- `context[:visibility_migration_warden_running] = true` is set in the _duplicate_ context which is passed to a `Warden` instance.
- If you set `context[:skip_migration_error] = true`, then no migration error will be raised for that query.

You can use these flags to conditionally handle edge cases that should be ignored in testing.
5 changes: 4 additions & 1 deletion guides/schema/dynamic_types.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ 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.
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.)

Expand Down
45 changes: 37 additions & 8 deletions lib/graphql/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,24 @@ def selected_operation_name
# @param root_value [Object] the object used to resolve fields on the root type
# @param max_depth [Numeric] the maximum number of nested selections allowed for this query (falls back to schema-level value)
# @param max_complexity [Numeric] the maximum field complexity for this query (falls back to schema-level value)
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)
# @param visibility_profile [Symbol]
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)
# Even if `variables: nil` is passed, use an empty hash for simpler logic
variables ||= {}
@schema = schema
@context = schema.context_class.new(query: self, values: context)

if use_schema_subset.nil?
use_schema_subset = warden ? false : schema.use_schema_visibility?
if use_visibility_profile.nil?
use_visibility_profile = warden ? false : schema.use_visibility_profile?
end

if use_schema_subset
@schema_subset = @schema.subset_class.new(context: @context, schema: @schema)
@visibility_profile = visibility_profile

if use_visibility_profile
@visibility_profile = @schema.visibility.profile_for(@context, visibility_profile)
@warden = Schema::Warden::NullWarden.new(context: @context, schema: @schema)
else
@schema_subset = nil
@visibility_profile = nil
@warden = warden
end

Expand Down Expand Up @@ -187,6 +190,9 @@ def query_string
@query_string ||= (document ? document.to_query_string : nil)
end

# @return [Symbol, nil]
attr_reader :visibility_profile

attr_accessor :multiplex

# @return [GraphQL::Tracing::Trace]
Expand Down Expand Up @@ -343,10 +349,33 @@ def warden
with_prepared_ast { @warden }
end

def_delegators :warden, :get_type, :get_field, :possible_types, :root_type_for_operation
def get_type(type_name)
types.type(type_name) # rubocop:disable Development/ContextIsPassedCop
end

def get_field(owner, field_name)
types.field(owner, field_name) # rubocop:disable Development/ContextIsPassedCop
end

def possible_types(type)
types.possible_types(type) # rubocop:disable Development/ContextIsPassedCop
end

def root_type_for_operation(op_type)
case op_type
when "query"
types.query_root # rubocop:disable Development/ContextIsPassedCop
when "mutation"
types.mutation_root # rubocop:disable Development/ContextIsPassedCop
when "subscription"
types.subscription_root # rubocop:disable Development/ContextIsPassedCop
else
raise ArgumentError, "unexpected root type name: #{op_type.inspect}; expected 'query', 'mutation', or 'subscription'"
end
end

def types
@schema_subset || warden.schema_subset
@visibility_profile || warden.visibility_profile
end

# @param abstract_type [GraphQL::UnionType, GraphQL::InterfaceType]
Expand Down
2 changes: 1 addition & 1 deletion lib/graphql/query/null_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def initialize
end

def types
@types ||= GraphQL::Schema::Warden::SchemaSubset.new(@warden)
@types ||= Schema::Warden::VisibilityProfile.new(@warden)
end
end
end
Expand Down
79 changes: 48 additions & 31 deletions lib/graphql/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,9 @@ def static_validator
GraphQL::StaticValidation::Validator.new(schema: self)
end

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

# @param type_name [String]
# @param context [GraphQL::Query::Context] Used for filtering definitions at query-time
# @param use_visibility_profile Private, for migration to {Schema::Visibility}
# @return [Module, nil] A type, or nil if there's no type called `type_name`
def get_type(type_name, context = GraphQL::Query::NullContext.instance)
if use_schema_visibility?
return Visibility::Subset.from_context(context, self).type(type_name)
def get_type(type_name, context = GraphQL::Query::NullContext.instance, use_visibility_profile = use_visibility_profile?)
if use_visibility_profile
return Visibility::Profile.from_context(context, self).type(type_name)
end
local_entry = own_types[type_name]
type_defn = case local_entry
when nil
nil
when Array
if context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Subset)
if context.respond_to?(:types) && context.types.is_a?(GraphQL::Schema::Visibility::Profile)
local_entry
else
visible_t = nil
Expand All @@ -398,7 +404,7 @@ def get_type(type_name, context = GraphQL::Query::NullContext.instance)

type_defn ||
introspection_system.types[type_name] || # todo context-specific introspection?
(superclass.respond_to?(:get_type) ? superclass.get_type(type_name, context) : nil)
(superclass.respond_to?(:get_type) ? superclass.get_type(type_name, context, use_visibility_profile) : nil)
end

# @return [Boolean] Does this schema have _any_ definition for a type named `type_name`, regardless of visibility?
Expand Down Expand Up @@ -430,7 +436,7 @@ def query(new_query_object = nil, &lazy_load_block)
if @query_object
dup_defn = new_query_object || yield
raise GraphQL::Error, "Second definition of `query(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@query_object.inspect}"
elsif use_schema_visibility?
elsif use_visibility_profile?
@query_object = block_given? ? lazy_load_block : new_query_object
else
@query_object = new_query_object || lazy_load_block.call
Expand All @@ -449,7 +455,7 @@ def mutation(new_mutation_object = nil, &lazy_load_block)
if @mutation_object
dup_defn = new_mutation_object || yield
raise GraphQL::Error, "Second definition of `mutation(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@mutation_object.inspect}"
elsif use_schema_visibility?
elsif use_visibility_profile?
@mutation_object = block_given? ? lazy_load_block : new_mutation_object
else
@mutation_object = new_mutation_object || lazy_load_block.call
Expand All @@ -468,7 +474,7 @@ def subscription(new_subscription_object = nil, &lazy_load_block)
if @subscription_object
dup_defn = new_subscription_object || yield
raise GraphQL::Error, "Second definition of `subscription(...)` (#{dup_defn.inspect}) is invalid, already configured with #{@subscription_object.inspect}"
elsif use_schema_visibility?
elsif use_visibility_profile?
@subscription_object = block_given? ? lazy_load_block : new_subscription_object
add_subscription_extension_if_necessary
else
Expand Down Expand Up @@ -502,7 +508,7 @@ def root_type_for_operation(operation)
end

def root_types
if use_schema_visibility?
if use_visibility_profile?
[query, mutation, subscription].compact
else
@root_types
Expand All @@ -521,37 +527,43 @@ def warden_class

attr_writer :warden_class

def subset_class
if defined?(@subset_class)
@subset_class
elsif superclass.respond_to?(:subset_class)
superclass.subset_class
# @api private
def visibility_profile_class
if defined?(@visibility_profile_class)
@visibility_profile_class
elsif superclass.respond_to?(:visibility_profile_class)
superclass.visibility_profile_class
else
GraphQL::Schema::Visibility::Subset
GraphQL::Schema::Visibility::Profile
end
end

attr_writer :subset_class, :use_schema_visibility, :visibility

def use_schema_visibility?
if defined?(@use_schema_visibility)
@use_schema_visibility
elsif superclass.respond_to?(:use_schema_visibility?)
superclass.use_schema_visibility?
# @api private
attr_writer :visibility_profile_class, :use_visibility_profile
# @api private
attr_accessor :visibility
# @api private
def use_visibility_profile?
if defined?(@use_visibility_profile)
@use_visibility_profile
elsif superclass.respond_to?(:use_visibility_profile?)
superclass.use_visibility_profile?
else
false
end
end

# @param type [Module] The type definition whose possible types you want to see
# @param context [GraphQL::Query::Context] used for filtering visible possible types at runtime
# @param use_visibility_profile Private, for migration to {Schema::Visibility}
# @return [Hash<String, Module>] All possible types, if no `type` is given.
# @return [Array<Module>] Possible types for `type`, if it's given.
def possible_types(type = nil, context = GraphQL::Query::NullContext.instance)
if use_schema_visibility?
def possible_types(type = nil, context = GraphQL::Query::NullContext.instance, use_visibility_profile = use_visibility_profile?)
if use_visibility_profile
if type
return Visibility::Subset.from_context(context, self).possible_types(type)
return Visibility::Profile.from_context(context, self).possible_types(type)
else
raise "Schema.possible_types is not implemented for `use_schema_visibility?`"
raise "Schema.possible_types is not implemented for `use_visibility_profile?`"
end
end
if type
Expand All @@ -571,7 +583,7 @@ def possible_types(type = nil, context = GraphQL::Query::NullContext.instance)
introspection_system.possible_types[type] ||
(
superclass.respond_to?(:possible_types) ?
superclass.possible_types(type, context) :
superclass.possible_types(type, context, use_visibility_profile) :
EMPTY_ARRAY
)
end
Expand Down Expand Up @@ -927,7 +939,7 @@ def orphan_types(*new_orphan_types)
To add other types to your schema, you might want `extra_types`: https://graphql-ruby.org/schema/definition.html#extra-types
ERR
end
add_type_and_traverse(new_orphan_types, root: false) unless use_schema_visibility?
add_type_and_traverse(new_orphan_types, root: false) unless use_visibility_profile?
own_orphan_types.concat(new_orphan_types.flatten)
end

Expand Down Expand Up @@ -1069,6 +1081,11 @@ def inherited(child_class)
child_class.own_trace_modes[name] = child_class.build_trace_mode(name)
end
child_class.singleton_class.prepend(ResolveTypeWithType)

if use_visibility_profile?
vis = self.visibility
child_class.visibility = vis.dup_for(child_class)
end
super
end

Expand Down Expand Up @@ -1186,7 +1203,7 @@ def directives(*new_directives)
# @param new_directive [Class]
# @return void
def directive(new_directive)
if use_schema_visibility?
if use_visibility_profile?
own_directives[new_directive.graphql_name] = new_directive
else
add_type_and_traverse(new_directive, root: false)
Expand Down
9 changes: 6 additions & 3 deletions lib/graphql/schema/always_visible.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# frozen_string_literal: true
module GraphQL
class Schema
class AlwaysVisible
module AlwaysVisible
def self.use(schema, **opts)
schema.warden_class = GraphQL::Schema::Warden::NullWarden
schema.subset_class = GraphQL::Schema::Warden::NullWarden::NullSubset
schema.extend(self)
end

def visible?(_member, _context)
true
end
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/schema/argument.rb
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ def load_and_authorize_value(load_method_owner, coerced_value, context)

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

0 comments on commit 3ddf36b

Please sign in to comment.