Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cached, named Visibility profiles #5074

Merged
merged 21 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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